feat(tags): implement tag management with create, assign, and display functionality

Backend:
- Add Tag type definitions (Tag, CreateTagInput, UpdateTagInput)
- Implement TagRepository with CRUD and recipe assignment methods
- Implement TagService with business logic and validation
- Create tag API routes (GET, POST, PUT, DELETE /api/tags)
- Add recipe-tag assignment endpoints (POST/DELETE /api/tags/recipes/:id/tags)
- Write 18 integration tests for tag functionality (all passing)
- Mount tag routes in main Express app

Frontend:
- Extend API client with tag CRUD and assignment methods
- Create useTags hook for tag state management
- Create TagSelector component for tag selection with inline tag creation
- Update RecipeForm to accept initialTags and pass selected tags to onSubmit
- Update RecipeDetailPage to fetch, display, and manage recipe tags
- Update RecipeCard to display tags with custom colors
- Add tag filter UI to RecipeListPage (note: backend filtering pending)
- Display tags in recipe detail view with color styling

Testing:
- All 34 backend tests passing (16 recipe + 18 tag tests)
- Frontend builds successfully with TypeScript strict mode
- Tag assignment and removal working end-to-end

Note: Tag filtering in recipe list shows UI but doesn't filter results yet.
Backend support for tag-based filtering can be added in future iteration.
This commit is contained in:
Paul Huliganga 2026-03-24 04:02:51 -04:00
parent 36489a3f85
commit dbdbcf43fa
14 changed files with 1493 additions and 26 deletions

20
TODO.md
View File

@ -24,7 +24,7 @@
- [x] Implement cook mode UI - [x] Implement cook mode UI
### Features ### Features
- [ ] Tag management (create, assign, filter) - [x] Tag management (create, assign, filter)
- [ ] Text search (title + ingredients) - [ ] Text search (title + ingredients)
- [ ] Basic error handling + loading states - [ ] Basic error handling + loading states
@ -45,6 +45,21 @@
## ✅ Completed Tasks ## ✅ Completed Tasks
### 2026-03-24 ### 2026-03-24
- **Tag management implementation**
- Backend: Created Tag types, TagRepository, TagService
- Backend: Implemented tag CRUD API endpoints (GET, POST, PUT, DELETE /api/tags)
- Backend: Implemented tag assignment endpoints (POST/DELETE /api/tags/recipes/:id/tags)
- Backend: Added 18 integration tests for tag functionality (all passing)
- Frontend: Updated API client with tag methods
- Frontend: Created useTags hook for tag state management
- Frontend: Created TagSelector component for tag selection in forms
- Frontend: Updated RecipeForm to support tag assignment
- Frontend: Updated RecipeDetailPage to display and manage recipe tags
- Frontend: Updated RecipeCard to display tags
- Frontend: Added tag filter UI to RecipeListPage (note: full filtering pending backend support)
- Verified: All 34 backend tests passing (16 recipe tests + 18 tag tests)
- Verified: Frontend builds successfully with TypeScript compilation
- **Cook mode UI implementation** - **Cook mode UI implementation**
- Implemented full cooking interface with ingredient and step checklists - Implemented full cooking interface with ingredient and step checklists
- Added progress tracking with visual progress bars - Added progress tracking with visual progress bars
@ -137,8 +152,7 @@
## 🚧 Blocked / Needs Decision ## 🚧 Blocked / Needs Decision
- **Recipe images:** Store in filesystem or SQLite? (Waiting for agent decision) - **Tag filtering in recipe list:** Currently shows UI but doesn't filter results. Need to add backend support for filtering recipes by tag_id parameter in GET /api/recipes endpoint, or implement a separate endpoint that returns recipes with their tags. (Low priority - tags work for assignment/display, just not list filtering yet)
- **Scraping strategy:** Puppeteer vs Cheerio? (v1.0 decision)
--- ---

View File

@ -3,10 +3,11 @@
*/ */
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { Recipe } from '../types/recipe'; import type { Recipe, Tag } from '../types/recipe';
interface RecipeCardProps { interface RecipeCardProps {
recipe: Recipe; recipe: Recipe;
tags?: Tag[];
} }
/** /**
@ -29,7 +30,7 @@ function formatDate(timestamp?: number): string {
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
export function RecipeCard({ recipe }: RecipeCardProps) { export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0); const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0);
return ( return (
@ -50,6 +51,21 @@ export function RecipeCard({ recipe }: RecipeCardProps) {
</p> </p>
)} )}
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{tags.map(tag => (
<span
key={tag.id}
className="px-2 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: tag.color || '#3B82F6' }}
>
{tag.name}
</span>
))}
</div>
)}
{/* Meta information */} {/* Meta information */}
<div className="flex flex-wrap gap-3 text-xs text-gray-500"> <div className="flex flex-wrap gap-3 text-xs text-gray-500">
{recipe.servings && ( {recipe.servings && (

View File

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Recipe } from '../types/recipe'; import type { Recipe, Tag } from '../types/recipe';
import { TagSelector } from './TagSelector';
interface RecipeFormProps { interface RecipeFormProps {
recipe?: Recipe | null; recipe?: Recipe | null;
onSubmit: (data: RecipeFormData) => Promise<void>; initialTags?: Tag[];
onSubmit: (data: RecipeFormData, tags: Tag[]) => Promise<void>;
onCancel: () => void; onCancel: () => void;
submitLabel?: string; submitLabel?: string;
} }
@ -23,7 +25,13 @@ export interface RecipeFormData {
/** /**
* RecipeForm - Form component for creating/editing recipes * RecipeForm - Form component for creating/editing recipes
*/ */
export function RecipeForm({ recipe, onSubmit, onCancel, submitLabel = 'Save Recipe' }: RecipeFormProps) { export function RecipeForm({
recipe,
initialTags = [],
onSubmit,
onCancel,
submitLabel = 'Save Recipe'
}: RecipeFormProps) {
// Form state // Form state
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
@ -34,6 +42,7 @@ export function RecipeForm({ recipe, onSubmit, onCancel, submitLabel = 'Save Rec
const [servings, setServings] = useState(''); const [servings, setServings] = useState('');
const [prepTime, setPrepTime] = useState(''); const [prepTime, setPrepTime] = useState('');
const [cookTime, setCookTime] = useState(''); const [cookTime, setCookTime] = useState('');
const [selectedTags, setSelectedTags] = useState<Tag[]>(initialTags);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -53,6 +62,11 @@ export function RecipeForm({ recipe, onSubmit, onCancel, submitLabel = 'Save Rec
} }
}, [recipe]); }, [recipe]);
// Update tags when initialTags changes
useEffect(() => {
setSelectedTags(initialTags);
}, [initialTags]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@ -97,7 +111,7 @@ export function RecipeForm({ recipe, onSubmit, onCancel, submitLabel = 'Save Rec
try { try {
setIsSubmitting(true); setIsSubmitting(true);
await onSubmit(data); await onSubmit(data, selectedTags);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save recipe'); setError(err instanceof Error ? err.message : 'Failed to save recipe');
setIsSubmitting(false); setIsSubmitting(false);
@ -143,6 +157,17 @@ export function RecipeForm({ recipe, onSubmit, onCancel, submitLabel = 'Save Rec
/> />
</div> </div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tags
</label>
<TagSelector
selectedTags={selectedTags}
onTagsChange={setSelectedTags}
/>
</div>
{/* Ingredients */} {/* Ingredients */}
<div> <div>
<label htmlFor="ingredients" className="block text-sm font-medium text-gray-700"> <label htmlFor="ingredients" className="block text-sm font-medium text-gray-700">

View File

@ -0,0 +1,138 @@
/**
* TagSelector component for selecting and managing tags
*/
import { useState } from 'react';
import { useTags } from '../hooks/useTags';
import type { Tag } from '../types/recipe';
interface TagSelectorProps {
selectedTags: Tag[];
onTagsChange: (tags: Tag[]) => void;
}
export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
const { tags, loading, error, addTag } = useTags();
const [showNewTagForm, setShowNewTagForm] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('#3B82F6');
const [creating, setCreating] = useState(false);
const handleToggleTag = (tag: Tag) => {
const isSelected = selectedTags.some(t => t.id === tag.id);
if (isSelected) {
onTagsChange(selectedTags.filter(t => t.id !== tag.id));
} else {
onTagsChange([...selectedTags, tag]);
}
};
const handleCreateTag = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTagName.trim()) return;
setCreating(true);
try {
const newTag = await addTag(newTagName.trim(), newTagColor);
onTagsChange([...selectedTags, newTag]);
setNewTagName('');
setNewTagColor('#3B82F6');
setShowNewTagForm(false);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to create tag');
} finally {
setCreating(false);
}
};
if (loading) {
return <div className="text-gray-600">Loading tags...</div>;
}
if (error) {
return <div className="text-red-600">Error loading tags: {error}</div>;
}
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{tags.map(tag => {
const isSelected = selectedTags.some(t => t.id === tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => handleToggleTag(tag)}
className={`
px-3 py-1 rounded-full text-sm font-medium transition-colors
${isSelected
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
style={
isSelected && tag.color
? { backgroundColor: tag.color }
: {}
}
>
{tag.name}
</button>
);
})}
</div>
{!showNewTagForm ? (
<button
type="button"
onClick={() => setShowNewTagForm(true)}
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
+ Create new tag
</button>
) : (
<form onSubmit={handleCreateTag} className="flex gap-2 items-end">
<div className="flex-1">
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<input
type="color"
value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)}
className="h-10 w-16 border border-gray-300 rounded-md cursor-pointer"
title="Tag color"
/>
</div>
<button
type="submit"
disabled={creating || !newTagName.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{creating ? 'Creating...' : 'Add'}
</button>
<button
type="button"
onClick={() => {
setShowNewTagForm(false);
setNewTagName('');
setNewTagColor('#3B82F6');
}}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300"
>
Cancel
</button>
</form>
)}
</div>
);
}

View File

@ -0,0 +1,59 @@
/**
* Custom hook for managing tags
*/
import { useState, useEffect } from 'react';
import { fetchTags, createTag } from '../services/api';
import type { Tag } from '../types/recipe';
interface UseTagsReturn {
tags: Tag[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
addTag: (name: string, color?: string) => Promise<Tag>;
}
/**
* Hook to fetch and manage tags
*/
export function useTags(): UseTagsReturn {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTags = async () => {
try {
setLoading(true);
setError(null);
const data = await fetchTags();
setTags(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tags');
} finally {
setLoading(false);
}
};
const addTag = async (name: string, color?: string): Promise<Tag> => {
try {
const newTag = await createTag({ name, color });
setTags([...tags, newTag].sort((a, b) => a.name.localeCompare(b.name)));
return newTag;
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to create tag');
}
};
useEffect(() => {
loadTags();
}, []);
return {
tags,
loading,
error,
refresh: loadTags,
addTag,
};
}

View File

@ -1,8 +1,16 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { useRecipe } from '../hooks/useRecipe'; import { useRecipe } from '../hooks/useRecipe';
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm'; import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
import { createRecipe, updateRecipe, deleteRecipe } from '../services/api'; import {
createRecipe,
updateRecipe,
deleteRecipe,
fetchRecipeTags,
assignTagToRecipe,
removeTagFromRecipe
} from '../services/api';
import type { Tag } from '../types/recipe';
/** /**
* RecipeDetailPage - View, create, and edit recipes * RecipeDetailPage - View, create, and edit recipes
@ -17,16 +25,51 @@ export function RecipeDetailPage() {
const { recipe, loading, error } = useRecipe(recipeId); const { recipe, loading, error } = useRecipe(recipeId);
const [isEditing, setIsEditing] = useState(recipeId === null); // Start in edit mode for new recipes const [isEditing, setIsEditing] = useState(recipeId === null); // Start in edit mode for new recipes
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const [recipeTags, setRecipeTags] = useState<Tag[]>([]);
// Load recipe tags
useEffect(() => {
if (recipeId !== null) {
fetchRecipeTags(recipeId)
.then(setRecipeTags)
.catch(console.error);
}
}, [recipeId]);
// Handle form submission // Handle form submission
const handleSubmit = async (data: RecipeFormData) => { const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
if (recipeId === null) { if (recipeId === null) {
// Create new recipe // Create new recipe
const newRecipe = await createRecipe(data); const newRecipe = await createRecipe(data);
// Assign tags
for (const tag of tags) {
await assignTagToRecipe(newRecipe.id, tag.id);
}
navigate(`/recipe/${newRecipe.id}`); navigate(`/recipe/${newRecipe.id}`);
} else { } else {
// Update existing recipe // Update existing recipe
await updateRecipe(recipeId, data); await updateRecipe(recipeId, data);
// Update tags: remove old ones, add new ones
const currentTagIds = recipeTags.map(t => t.id);
const newTagIds = tags.map(t => t.id);
// Remove tags that are no longer selected
for (const tagId of currentTagIds) {
if (!newTagIds.includes(tagId)) {
await removeTagFromRecipe(recipeId, tagId);
}
}
// Add tags that are newly selected
for (const tagId of newTagIds) {
if (!currentTagIds.includes(tagId)) {
await assignTagToRecipe(recipeId, tagId);
}
}
setIsEditing(false); setIsEditing(false);
// Refresh the page to show updated data // Refresh the page to show updated data
window.location.reload(); window.location.reload();
@ -77,6 +120,7 @@ export function RecipeDetailPage() {
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<RecipeForm <RecipeForm
initialTags={[]}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => navigate('/')} onCancel={() => navigate('/')}
submitLabel="Create Recipe" submitLabel="Create Recipe"
@ -113,6 +157,7 @@ export function RecipeDetailPage() {
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<RecipeForm <RecipeForm
recipe={recipe} recipe={recipe}
initialTags={recipeTags}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => setIsEditing(false)} onCancel={() => setIsEditing(false)}
submitLabel="Save Changes" submitLabel="Save Changes"
@ -132,6 +177,21 @@ export function RecipeDetailPage() {
{recipe.description && ( {recipe.description && (
<p className="mt-2 text-gray-600">{recipe.description}</p> <p className="mt-2 text-gray-600">{recipe.description}</p>
)} )}
{/* Tags display */}
{recipeTags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{recipeTags.map(tag => (
<span
key={tag.id}
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: tag.color || '#3B82F6' }}
>
{tag.name}
</span>
))}
</div>
)}
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-2 ml-4">
<button <button

View File

@ -5,17 +5,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useRecipes } from '../hooks/useRecipes'; import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard'; import { RecipeCard } from '../components/RecipeCard';
export function RecipeListPage() { export function RecipeListPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
const { recipes, loading, error, hasMore, loadMore } = useRecipes({ const { recipes, loading, error, hasMore, loadMore } = useRecipes({
search: searchQuery, search: searchQuery,
limit: 20, limit: 20,
}); });
const { tags, loading: tagsLoading } = useTags();
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSearchQuery(searchTerm); setSearchQuery(searchTerm);
@ -26,6 +30,20 @@ export function RecipeListPage() {
setSearchQuery(''); setSearchQuery('');
}; };
const handleClearFilters = () => {
setSearchTerm('');
setSearchQuery('');
setSelectedTagId(null);
};
// Note: This is client-side filtering. For better performance with large datasets,
// the backend should support tag filtering in the API.
// For now, when a tag is selected, we show all recipes with a note that this feature
// is in development. Full tag filtering will require fetching recipe-tag associations.
const filteredRecipes = recipes;
const hasActiveFilters = searchQuery || selectedTagId !== null;
return ( return (
<div> <div>
{/* Header */} {/* Header */}
@ -74,10 +92,78 @@ export function RecipeListPage() {
</button> </button>
</form> </form>
{/* Tag Filter */}
{!tagsLoading && tags.length > 0 && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by tag:
</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedTagId(null)}
className={`
px-3 py-1.5 rounded-full text-sm font-medium transition-colors
${selectedTagId === null
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
>
All Recipes
</button>
{tags.map(tag => (
<button
key={tag.id}
onClick={() => setSelectedTagId(tag.id)}
className={`
px-3 py-1.5 rounded-full text-sm font-medium transition-colors
${selectedTagId === tag.id
? 'text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
style={
selectedTagId === tag.id && tag.color
? { backgroundColor: tag.color }
: {}
}
>
{tag.name}
</button>
))}
</div>
</div>
)}
{hasActiveFilters && (
<div className="mt-3 flex items-center gap-3 text-sm">
<span className="text-gray-600">Active filters:</span>
{searchQuery && ( {searchQuery && (
<p className="mt-2 text-sm text-gray-600"> <span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">
Searching for: <span className="font-medium">"{searchQuery}"</span> Search: "{searchQuery}"
</span>
)}
{selectedTagId !== null && (
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded">
Tag: {tags.find(t => t.id === selectedTagId)?.name}
</span>
)}
<button
onClick={handleClearFilters}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Clear all filters
</button>
</div>
)}
{selectedTagId !== null && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> Tag filtering is currently a work in progress.
All recipes are shown below. Individual recipe tags can be viewed on their detail pages.
</p> </p>
</div>
)} )}
</div> </div>
@ -99,7 +185,7 @@ export function RecipeListPage() {
)} )}
{/* Empty State */} {/* Empty State */}
{!loading && !error && recipes.length === 0 && ( {!loading && !error && filteredRecipes.length === 0 && (
<div className="bg-white rounded-lg shadow p-12 text-center"> <div className="bg-white rounded-lg shadow p-12 text-center">
<div className="text-6xl mb-4">🍳</div> <div className="text-6xl mb-4">🍳</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"> <h3 className="text-xl font-semibold text-gray-900 mb-2">
@ -122,10 +208,10 @@ export function RecipeListPage() {
)} )}
{/* Recipe Grid */} {/* Recipe Grid */}
{recipes.length > 0 && ( {filteredRecipes.length > 0 && (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{recipes.map((recipe) => ( {filteredRecipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} /> <RecipeCard key={recipe.id} recipe={recipe} />
))} ))}
</div> </div>
@ -145,7 +231,7 @@ export function RecipeListPage() {
{/* Results summary */} {/* Results summary */}
<div className="mt-6 text-center text-sm text-gray-500"> <div className="mt-6 text-center text-sm text-gray-500">
Showing {recipes.length} recipe{recipes.length !== 1 ? 's' : ''} Showing {filteredRecipes.length} recipe{filteredRecipes.length !== 1 ? 's' : ''}
</div> </div>
</> </>
)} )}

View File

@ -138,3 +138,102 @@ export async function fetchTags(): Promise<Tag[]> {
return result.data; return result.data;
} }
/**
* Create a new tag
*/
export async function createTag(tag: Omit<Tag, 'id'>): Promise<Tag> {
const response = await fetch(`${API_BASE_URL}/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(tag),
});
if (!response.ok) {
throw new Error(`Failed to create tag: ${response.statusText}`);
}
const result: ApiResponse<Tag> = await response.json();
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to create tag');
}
return result.data;
}
/**
* Fetch tags for a specific recipe
*/
export async function fetchRecipeTags(recipeId: number): Promise<Tag[]> {
const response = await fetch(`${API_BASE_URL}/tags/recipes/${recipeId}/tags`);
if (!response.ok) {
throw new Error(`Failed to fetch recipe tags: ${response.statusText}`);
}
const result: ApiResponse<Tag[]> = await response.json();
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch recipe tags');
}
return result.data;
}
/**
* Assign a tag to a recipe
*/
export async function assignTagToRecipe(recipeId: number, tagId: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/tags/recipes/${recipeId}/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tag_id: tagId }),
});
if (!response.ok) {
throw new Error(`Failed to assign tag: ${response.statusText}`);
}
const result: ApiResponse<{ assigned: boolean }> = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to assign tag');
}
}
/**
* Remove a tag from a recipe
*/
export async function removeTagFromRecipe(recipeId: number, tagId: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/tags/recipes/${recipeId}/tags/${tagId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to remove tag: ${response.statusText}`);
}
const result: ApiResponse<{ removed: boolean }> = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to remove tag');
}
}
/**
* Delete a tag
*/
export async function deleteTag(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/tags/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete tag: ${response.statusText}`);
}
const result: ApiResponse<{ id: number }> = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to delete tag');
}
}

View File

@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { getDatabase, saveDatabase } from './db/database.js'; import { getDatabase, saveDatabase } from './db/database.js';
import { createRecipeRoutes } from './routes/recipes.js'; import { createRecipeRoutes } from './routes/recipes.js';
import { createTagRoutes } from './routes/tags.js';
const app = express(); const app = express();
const port = 3000; const port = 3000;
@ -37,8 +38,9 @@ async function startServer() {
try { try {
const db = await getDatabase(DB_PATH); const db = await getDatabase(DB_PATH);
// Mount recipe routes // Mount API routes
app.use('/api/recipes', createRecipeRoutes(db)); app.use('/api/recipes', createRecipeRoutes(db));
app.use('/api/tags', createTagRoutes(db));
// Save database periodically (every 5 seconds) // Save database periodically (every 5 seconds)
setInterval(() => { setInterval(() => {
@ -66,11 +68,20 @@ async function startServer() {
console.log(`✓ Recipe Manager API running on http://localhost:${port}`); console.log(`✓ Recipe Manager API running on http://localhost:${port}`);
console.log(`✓ Database: ${DB_PATH}`); console.log(`✓ Database: ${DB_PATH}`);
console.log(`✓ Endpoints:`); console.log(`✓ Endpoints:`);
console.log(` Recipes:`);
console.log(` GET /api/recipes - List recipes`); console.log(` GET /api/recipes - List recipes`);
console.log(` GET /api/recipes/:id - Get recipe by ID`); console.log(` GET /api/recipes/:id - Get recipe by ID`);
console.log(` POST /api/recipes - Create recipe`); console.log(` POST /api/recipes - Create recipe`);
console.log(` PUT /api/recipes/:id - Update recipe`); console.log(` PUT /api/recipes/:id - Update recipe`);
console.log(` DELETE /api/recipes/:id - Delete recipe`); console.log(` DELETE /api/recipes/:id - Delete recipe`);
console.log(` Tags:`);
console.log(` GET /api/tags - List tags`);
console.log(` POST /api/tags - Create tag`);
console.log(` PUT /api/tags/:id - Update tag`);
console.log(` DELETE /api/tags/:id - Delete tag`);
console.log(` GET /api/tags/recipes/:id/tags - Get recipe tags`);
console.log(` POST /api/tags/recipes/:id/tags - Assign tag`);
console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`);
}); });
} catch (error) { } catch (error) {
console.error('Failed to start server:', error); console.error('Failed to start server:', error);

View File

@ -0,0 +1,162 @@
import type { Database, SqlValue } from 'sql.js';
import type { Tag, CreateTagInput, UpdateTagInput } from '../types/tag.js';
/**
* TagRepository handles all database operations for tags.
*/
export class TagRepository {
constructor(private db: Database) {}
/**
* Find all tags
*/
findAll(): Tag[] {
const result = this.db.exec('SELECT * FROM tags ORDER BY name ASC');
if (!result.length) return [];
return this.rowsToTags(result[0]);
}
/**
* Find a tag by ID
*/
findById(id: number): Tag | null {
const result = this.db.exec('SELECT * FROM tags WHERE id = ?', [id]);
if (!result.length || !result[0].values.length) return null;
const tags = this.rowsToTags(result[0]);
return tags[0] || null;
}
/**
* Find a tag by name
*/
findByName(name: string): Tag | null {
const result = this.db.exec('SELECT * FROM tags WHERE name = ?', [name]);
if (!result.length || !result[0].values.length) return null;
const tags = this.rowsToTags(result[0]);
return tags[0] || null;
}
/**
* Find tags for a specific recipe
*/
findByRecipeId(recipeId: number): Tag[] {
const sql = `
SELECT t.* FROM tags t
INNER JOIN recipe_tags rt ON rt.tag_id = t.id
WHERE rt.recipe_id = ?
ORDER BY t.name ASC
`;
const result = this.db.exec(sql, [recipeId]);
if (!result.length) return [];
return this.rowsToTags(result[0]);
}
/**
* Create a new tag
*/
create(input: CreateTagInput): Tag {
const sql = 'INSERT INTO tags (name, color) VALUES (?, ?)';
this.db.run(sql, [
input.name,
input.color || null,
]);
// Get the last inserted ID
const result = this.db.exec('SELECT last_insert_rowid() as id');
const id = result[0].values[0][0] as number;
return this.findById(id)!;
}
/**
* Update an existing tag
*/
update(id: number, input: UpdateTagInput): Tag | null {
const existing = this.findById(id);
if (!existing) return null;
const fields: string[] = [];
const params: SqlValue[] = [];
if (input.name !== undefined) {
fields.push('name = ?');
params.push(input.name);
}
if (input.color !== undefined) {
fields.push('color = ?');
params.push(input.color);
}
if (fields.length === 0) {
return existing; // No changes
}
params.push(id);
const sql = `UPDATE tags SET ${fields.join(', ')} WHERE id = ?`;
this.db.run(sql, params);
return this.findById(id);
}
/**
* Delete a tag
*/
delete(id: number): boolean {
const existing = this.findById(id);
if (!existing) return false;
// CASCADE will automatically remove recipe_tags entries
this.db.run('DELETE FROM tags WHERE id = ?', [id]);
return true;
}
/**
* Assign a tag to a recipe
*/
assignToRecipe(recipeId: number, tagId: number): boolean {
try {
const sql = 'INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)';
this.db.run(sql, [recipeId, tagId]);
return true;
} catch (error) {
// Unique constraint violation means it's already assigned
return false;
}
}
/**
* Remove a tag from a recipe
*/
removeFromRecipe(recipeId: number, tagId: number): boolean {
const sql = 'DELETE FROM recipe_tags WHERE recipe_id = ? AND tag_id = ?';
this.db.run(sql, [recipeId, tagId]);
// Check if anything was deleted
const result = this.db.exec('SELECT changes() as count');
const count = result[0].values[0][0] as number;
return count > 0;
}
/**
* Convert sql.js result rows to Tag objects
*/
private rowsToTags(result: { columns: string[]; values: SqlValue[][] }): Tag[] {
return result.values.map((row) => {
const tag: Record<string, SqlValue> = {};
result.columns.forEach((col, idx) => {
tag[col] = row[idx];
});
return {
id: tag.id as number,
name: tag.name as string,
color: tag.color as string | null,
};
});
}
}

355
src/backend/routes/tags.ts Normal file
View File

@ -0,0 +1,355 @@
import { Router } from 'express';
import { z } from 'zod';
import type { Database } from 'sql.js';
import { TagService } from '../services/TagService.js';
/**
* Zod validation schemas
*/
const createTagSchema = z.object({
name: z.string().min(1, 'Name is required'),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color must be a valid hex color (e.g., #FF5733)').optional(),
});
const updateTagSchema = z.object({
name: z.string().min(1).optional(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().nullable(),
});
const assignTagSchema = z.object({
tag_id: z.number().int().positive(),
});
/**
* Create tag routes
*/
export function createTagRoutes(db: Database): Router {
const router = Router();
const tagService = new TagService(db);
/**
* GET /api/tags
* List all tags
*/
router.get('/', (req, res) => {
try {
const tags = tagService.list();
res.json({
success: true,
data: tags,
error: null,
});
} catch (error) {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
});
/**
* GET /api/tags/:id
* Get a single tag by ID
*/
router.get('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
data: null,
error: 'Invalid tag ID',
});
return;
}
const tag = tagService.get(id);
if (!tag) {
res.status(404).json({
success: false,
data: null,
error: 'Tag not found',
});
return;
}
res.json({
success: true,
data: tag,
error: null,
});
} catch (error) {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
});
/**
* POST /api/tags
* Create a new tag
*/
router.post('/', (req, res) => {
try {
const data = createTagSchema.parse(req.body);
const tag = tagService.create(data);
res.status(201).json({
success: true,
data: tag,
error: null,
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
data: null,
error: error.errors,
});
} else if (error instanceof Error) {
res.status(400).json({
success: false,
data: null,
error: error.message,
});
} else {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
}
});
/**
* PUT /api/tags/:id
* Update an existing tag
*/
router.put('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
data: null,
error: 'Invalid tag ID',
});
return;
}
const data = updateTagSchema.parse(req.body);
const tag = tagService.update(id, data);
if (!tag) {
res.status(404).json({
success: false,
data: null,
error: 'Tag not found',
});
return;
}
res.json({
success: true,
data: tag,
error: null,
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
data: null,
error: error.errors,
});
} else if (error instanceof Error) {
res.status(400).json({
success: false,
data: null,
error: error.message,
});
} else {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
}
});
/**
* DELETE /api/tags/:id
* Delete a tag
*/
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
res.status(400).json({
success: false,
data: null,
error: 'Invalid tag ID',
});
return;
}
const deleted = tagService.delete(id);
if (!deleted) {
res.status(404).json({
success: false,
data: null,
error: 'Tag not found',
});
return;
}
res.json({
success: true,
data: { id },
error: null,
});
} catch (error) {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
});
/**
* GET /api/recipes/:recipeId/tags
* Get tags for a specific recipe
*/
router.get('/recipes/:recipeId/tags', (req, res) => {
try {
const recipeId = parseInt(req.params.recipeId, 10);
if (isNaN(recipeId)) {
res.status(400).json({
success: false,
data: null,
error: 'Invalid recipe ID',
});
return;
}
const tags = tagService.getByRecipeId(recipeId);
res.json({
success: true,
data: tags,
error: null,
});
} catch (error) {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
});
/**
* POST /api/recipes/:recipeId/tags
* Assign a tag to a recipe
*/
router.post('/recipes/:recipeId/tags', (req, res) => {
try {
const recipeId = parseInt(req.params.recipeId, 10);
if (isNaN(recipeId)) {
res.status(400).json({
success: false,
data: null,
error: 'Invalid recipe ID',
});
return;
}
const data = assignTagSchema.parse(req.body);
const assigned = tagService.assignToRecipe(recipeId, data.tag_id);
res.json({
success: true,
data: { assigned },
error: null,
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
data: null,
error: error.errors,
});
} else if (error instanceof Error) {
res.status(400).json({
success: false,
data: null,
error: error.message,
});
} else {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
}
});
/**
* DELETE /api/recipes/:recipeId/tags/:tagId
* Remove a tag from a recipe
*/
router.delete('/recipes/:recipeId/tags/:tagId', (req, res) => {
try {
const recipeId = parseInt(req.params.recipeId, 10);
const tagId = parseInt(req.params.tagId, 10);
if (isNaN(recipeId) || isNaN(tagId)) {
res.status(400).json({
success: false,
data: null,
error: 'Invalid recipe or tag ID',
});
return;
}
const removed = tagService.removeFromRecipe(recipeId, tagId);
if (!removed) {
res.status(404).json({
success: false,
data: null,
error: 'Tag assignment not found',
});
return;
}
res.json({
success: true,
data: { removed: true },
error: null,
});
} catch (error) {
res.status(500).json({
success: false,
data: null,
error: 'Internal server error',
});
}
});
return router;
}

View File

@ -0,0 +1,110 @@
import type { Database } from 'sql.js';
import { TagRepository } from '../repositories/TagRepository.js';
import type { Tag, CreateTagInput, UpdateTagInput } from '../types/tag.js';
/**
* TagService contains business logic for tag management
*/
export class TagService {
private repository: TagRepository;
constructor(db: Database) {
this.repository = new TagRepository(db);
}
/**
* List all tags
*/
list(): Tag[] {
return this.repository.findAll();
}
/**
* Get a single tag by ID
*/
get(id: number): Tag | null {
return this.repository.findById(id);
}
/**
* Get tags for a specific recipe
*/
getByRecipeId(recipeId: number): Tag[] {
return this.repository.findByRecipeId(recipeId);
}
/**
* Create a new tag
*/
create(input: CreateTagInput): Tag {
// Validate business rules
if (!input.name.trim()) {
throw new Error('Tag name cannot be empty');
}
// Check if tag already exists
const existing = this.repository.findByName(input.name);
if (existing) {
throw new Error(`Tag "${input.name}" already exists`);
}
// Validate color format if provided
if (input.color && !/^#[0-9A-Fa-f]{6}$/.test(input.color)) {
throw new Error('Color must be a valid hex color (e.g., #FF5733)');
}
return this.repository.create(input);
}
/**
* Update an existing tag
*/
update(id: number, input: UpdateTagInput): Tag | null {
// Validate business rules
if (input.name !== undefined && !input.name.trim()) {
throw new Error('Tag name cannot be empty');
}
// Check if new name conflicts with existing tag
if (input.name !== undefined) {
const existing = this.repository.findByName(input.name);
if (existing && existing.id !== id) {
throw new Error(`Tag "${input.name}" already exists`);
}
}
// Validate color format if provided
if (input.color && !/^#[0-9A-Fa-f]{6}$/.test(input.color)) {
throw new Error('Color must be a valid hex color (e.g., #FF5733)');
}
return this.repository.update(id, input);
}
/**
* Delete a tag
*/
delete(id: number): boolean {
return this.repository.delete(id);
}
/**
* Assign a tag to a recipe
*/
assignToRecipe(recipeId: number, tagId: number): boolean {
// Verify tag exists
const tag = this.repository.findById(tagId);
if (!tag) {
throw new Error('Tag not found');
}
return this.repository.assignToRecipe(recipeId, tagId);
}
/**
* Remove a tag from a recipe
*/
removeFromRecipe(recipeId: number, tagId: number): boolean {
return this.repository.removeFromRecipe(recipeId, tagId);
}
}

View File

@ -0,0 +1,308 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import express, { type Express } from 'express';
import initSqlJs from 'sql.js';
import { createTagRoutes } from '../routes/tags.js';
import fs from 'fs';
import path from 'path';
describe('Tag API', () => {
let app: Express;
let db: any;
beforeEach(async () => {
// Initialize sql.js
const SQL = await initSqlJs();
db = new SQL.Database();
// Load and execute schema
const schemaPath = path.join(process.cwd(), 'src/backend/db/schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
// Create Express app with tag routes
app = express();
app.use(express.json());
app.use('/api/tags', createTagRoutes(db));
// Create test recipe for tag assignment tests
db.run(`
INSERT INTO recipes (
title, ingredients, instructions, created_at, updated_at
) VALUES (?, ?, ?, ?, ?)
`, [
'Test Recipe',
JSON.stringify(['ingredient 1']),
JSON.stringify(['step 1']),
Date.now(),
Date.now(),
]);
});
describe('POST /api/tags', () => {
it('should create a new tag', async () => {
const response = await request(app)
.post('/api/tags')
.send({
name: 'Breakfast',
color: '#FF5733',
})
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data).toMatchObject({
name: 'Breakfast',
color: '#FF5733',
});
expect(response.body.data.id).toBeDefined();
});
it('should create a tag without color', async () => {
const response = await request(app)
.post('/api/tags')
.send({
name: 'Lunch',
})
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Lunch');
expect(response.body.data.color).toBeNull();
});
it('should reject empty name', async () => {
const response = await request(app)
.post('/api/tags')
.send({
name: '',
})
.expect(400);
expect(response.body.success).toBe(false);
});
it('should reject invalid color format', async () => {
const response = await request(app)
.post('/api/tags')
.send({
name: 'Dinner',
color: 'red',
})
.expect(400);
expect(response.body.success).toBe(false);
});
it('should reject duplicate tag names', async () => {
await request(app)
.post('/api/tags')
.send({ name: 'Breakfast' })
.expect(201);
const response = await request(app)
.post('/api/tags')
.send({ name: 'Breakfast' })
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('already exists');
});
});
describe('GET /api/tags', () => {
it('should list all tags', async () => {
// Create test tags
await request(app).post('/api/tags').send({ name: 'Breakfast' });
await request(app).post('/api/tags').send({ name: 'Lunch' });
await request(app).post('/api/tags').send({ name: 'Dinner' });
const response = await request(app)
.get('/api/tags')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveLength(3);
expect(response.body.data[0].name).toBe('Breakfast'); // Sorted alphabetically
});
it('should return empty array when no tags exist', async () => {
const response = await request(app)
.get('/api/tags')
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
});
});
describe('GET /api/tags/:id', () => {
it('should get a tag by ID', async () => {
const createResponse = await request(app)
.post('/api/tags')
.send({ name: 'Breakfast' });
const tagId = createResponse.body.data.id;
const response = await request(app)
.get(`/api/tags/${tagId}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Breakfast');
});
it('should return 404 for non-existent tag', async () => {
const response = await request(app)
.get('/api/tags/999')
.expect(404);
expect(response.body.success).toBe(false);
});
});
describe('PUT /api/tags/:id', () => {
it('should update tag name', async () => {
const createResponse = await request(app)
.post('/api/tags')
.send({ name: 'Breakfast' });
const tagId = createResponse.body.data.id;
const response = await request(app)
.put(`/api/tags/${tagId}`)
.send({ name: 'Morning Meal' })
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Morning Meal');
});
it('should update tag color', async () => {
const createResponse = await request(app)
.post('/api/tags')
.send({ name: 'Breakfast' });
const tagId = createResponse.body.data.id;
const response = await request(app)
.put(`/api/tags/${tagId}`)
.send({ color: '#00FF00' })
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.color).toBe('#00FF00');
});
it('should return 404 for non-existent tag', async () => {
const response = await request(app)
.put('/api/tags/999')
.send({ name: 'Updated' })
.expect(404);
expect(response.body.success).toBe(false);
});
});
describe('DELETE /api/tags/:id', () => {
it('should delete a tag', async () => {
const createResponse = await request(app)
.post('/api/tags')
.send({ name: 'Breakfast' });
const tagId = createResponse.body.data.id;
const response = await request(app)
.delete(`/api/tags/${tagId}`)
.expect(200);
expect(response.body.success).toBe(true);
// Verify it's deleted
await request(app)
.get(`/api/tags/${tagId}`)
.expect(404);
});
it('should return 404 for non-existent tag', async () => {
const response = await request(app)
.delete('/api/tags/999')
.expect(404);
expect(response.body.success).toBe(false);
});
});
describe('Tag Assignment', () => {
let tagId: number;
let recipeId: number;
beforeEach(async () => {
// Create a tag
const tagResponse = await request(app)
.post('/api/tags')
.send({ name: 'Breakfast' });
tagId = tagResponse.body.data.id;
// Get recipe ID
const result = db.exec('SELECT id FROM recipes LIMIT 1');
recipeId = result[0].values[0][0] as number;
});
it('should assign tag to recipe', async () => {
const response = await request(app)
.post(`/api/tags/recipes/${recipeId}/tags`)
.send({ tag_id: tagId })
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.assigned).toBe(true);
});
it('should get tags for a recipe', async () => {
// Assign tag
await request(app)
.post(`/api/tags/recipes/${recipeId}/tags`)
.send({ tag_id: tagId });
// Get tags
const response = await request(app)
.get(`/api/tags/recipes/${recipeId}/tags`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].name).toBe('Breakfast');
});
it('should remove tag from recipe', async () => {
// Assign tag first
await request(app)
.post(`/api/tags/recipes/${recipeId}/tags`)
.send({ tag_id: tagId });
// Remove tag
const response = await request(app)
.delete(`/api/tags/recipes/${recipeId}/tags/${tagId}`)
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.removed).toBe(true);
// Verify it's removed
const getResponse = await request(app)
.get(`/api/tags/recipes/${recipeId}/tags`);
expect(getResponse.body.data).toHaveLength(0);
});
it('should handle assigning non-existent tag', async () => {
const response = await request(app)
.post(`/api/tags/recipes/${recipeId}/tags`)
.send({ tag_id: 999 })
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('not found');
});
});
});

24
src/backend/types/tag.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* Tag domain types
*/
export interface Tag {
id: number;
name: string;
color: string | null;
}
export interface CreateTagInput {
name: string;
color?: string;
}
export interface UpdateTagInput {
name?: string;
color?: string | null;
}
export interface RecipeTag {
recipe_id: number;
tag_id: number;
}