feat: recipe type (dish/drink) for better cocktail handling (v0.31.0)

Add recipes.recipeType enum (dish|drink, default dish). Recipe form
now shows a Type selector and hides food-only fields for drinks
(prep/cook time, difficulty, batch-cook toggle) instead of forcing
every cocktail through meal-oriented UI. Detail page skips the
meal/drink pairing suggestion buttons on drink recipes (pairing a
drink with a drink doesn't make sense). Recipes list gains a Type
filter facet alongside difficulty/batch-cook.

Scoped deliberately narrow per investigation: no ABV/glass-type/
garnish structured fields, no meal-plan schema changes, and the
existing AI drink-pairing-suggestion feature (ephemeral, never saved
as a recipe) is left as-is rather than wired into recipe creation —
those suggestions are wine/beer/spirit recommendations without
ingredients/steps, not really recipes to save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 14:16:43 +02:00
parent 894784afda
commit 6f11dc07fd
18 changed files with 5215 additions and 58 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.31.0 — 2026-07-14 16:35
### Added
- **Recipes can now be marked as a Drink/cocktail** instead of a dish — the recipe form hides irrelevant food-only fields (prep/cook time, difficulty, batch cooking) for drinks, and you can filter your recipe list by type. Drink recipes also no longer show the food/drink pairing suggestion buttons on their own page.
## 0.30.1 — 2026-07-14 16:20 ## 0.30.1 — 2026-07-14 16:20
Internal: fixed test mocks left stale by the earlier API-key auth-widening pass (recipes/meal-plans/shopping-lists route tests) — no user-facing change. Internal: fixed test mocks left stale by the earlier API-key auth-widening pass (recipes/meal-plans/shopping-lists route tests) — no user-facing change.
@@ -35,6 +35,7 @@ export default async function EditRecipePage({ params }: Params) {
title: recipe.title, title: recipe.title,
description: recipe.description ?? undefined, description: recipe.description ?? undefined,
baseServings: recipe.baseServings, baseServings: recipe.baseServings,
recipeType: recipe.recipeType,
visibility: recipe.visibility, visibility: recipe.visibility,
difficulty: recipe.difficulty, difficulty: recipe.difficulty,
prepMins: recipe.prepMins, prepMins: recipe.prepMins,
+1 -1
View File
@@ -166,7 +166,7 @@ export default async function RecipePage({ params }: Params) {
</Tooltip> </Tooltip>
)} )}
<FavoriteButton recipeId={id} initialFavorited={isFavorited} /> <FavoriteButton recipeId={id} initialFavorited={isFavorited} />
{!recipe.isBatchCook && ( {!recipe.isBatchCook && recipe.recipeType !== "drink" && (
<> <>
<MealPairingButton recipeId={id} /> <MealPairingButton recipeId={id} />
<DrinkPairingButton recipeId={id} /> <DrinkPairingButton recipeId={id} />
+7 -2
View File
@@ -22,6 +22,7 @@ type SearchParams = Promise<{
tag?: string; tag?: string;
page?: string; page?: string;
batchCook?: string; batchCook?: string;
recipeType?: string;
}>; }>;
const SORT_MAP = { const SORT_MAP = {
@@ -40,7 +41,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
if (!session) return null; if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale); const m = getMessages((session.user as { locale?: string }).locale);
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook } = await searchParams; const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType } = await searchParams;
const query = (q ?? "").trim().slice(0, 200); const query = (q ?? "").trim().slice(0, 200);
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey; const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
const tagFilter = tag?.trim().slice(0, 50) || undefined; const tagFilter = tag?.trim().slice(0, 50) || undefined;
@@ -54,6 +55,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
? (difficulty as "easy" | "medium" | "hard") ? (difficulty as "easy" | "medium" | "hard")
: undefined; : undefined;
const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined; const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined;
const recipeTypeFilter = recipeType === "dish" || recipeType === "drink" ? recipeType : undefined;
// Escape ilike wildcard chars (% and _) so a search like "100%" matches literally. // Escape ilike wildcard chars (% and _) so a search like "100%" matches literally.
const escapedQuery = query.replace(/[\\%_]/g, (c) => `\\${c}`); const escapedQuery = query.replace(/[\\%_]/g, (c) => `\\${c}`);
@@ -78,6 +80,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined, visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined, difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined, tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
recipeTypeFilter ? eq(recipes.recipeType, recipeTypeFilter) : undefined,
); );
const [userRecipes, totalRow] = await Promise.all([ const [userRecipes, totalRow] = await Promise.all([
@@ -112,6 +115,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
if (difficultyFilter) params.set("difficulty", difficultyFilter); if (difficultyFilter) params.set("difficulty", difficultyFilter);
if (tagFilter) params.set("tag", tagFilter); if (tagFilter) params.set("tag", tagFilter);
if (batchCookFilter) params.set("batchCook", batchCookFilter); if (batchCookFilter) params.set("batchCook", batchCookFilter);
if (recipeTypeFilter) params.set("recipeType", recipeTypeFilter);
if (p > 1) params.set("page", String(p)); if (p > 1) params.set("page", String(p));
const qs = params.toString(); const qs = params.toString();
return qs ? `/recipes?${qs}` : "/recipes"; return qs ? `/recipes?${qs}` : "/recipes";
@@ -133,9 +137,10 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
initialDifficulty={difficultyFilter ?? ""} initialDifficulty={difficultyFilter ?? ""}
initialTag={tagFilter ?? ""} initialTag={tagFilter ?? ""}
initialBatchCook={batchCookFilter ?? ""} initialBatchCook={batchCookFilter ?? ""}
initialRecipeType={recipeTypeFilter ?? ""}
/> />
<RecipesEmptyState query={query} count={total} /> <RecipesEmptyState query={query} count={total} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${page}`} recipes={recipesWithFavorites} /> <RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2"> <div className="flex items-center justify-center gap-2 pt-2">
@@ -12,6 +12,7 @@ const UpdateRecipeSchema = z.object({
title: z.string().min(1).max(200).optional(), title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).optional(), baseServings: z.number().int().min(1).max(100).optional(),
recipeType: z.enum(["dish", "drink"]).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(), visibility: z.enum(["private", "unlisted", "public"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(), difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(), prepMins: z.number().int().min(0).max(1440).nullable().optional(),
@@ -150,6 +151,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
if (data.title !== undefined) updates.title = data.title; if (data.title !== undefined) updates.title = data.title;
if (data.description !== undefined) updates.description = data.description; if (data.description !== undefined) updates.description = data.description;
if (data.baseServings !== undefined) updates.baseServings = data.baseServings; if (data.baseServings !== undefined) updates.baseServings = data.baseServings;
if (data.recipeType !== undefined) updates.recipeType = data.recipeType;
if (data.visibility !== undefined) updates.visibility = data.visibility; if (data.visibility !== undefined) updates.visibility = data.visibility;
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined; if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined; if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
+4
View File
@@ -13,6 +13,7 @@ const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4), baseServings: z.number().int().min(1).max(100).default(4),
recipeType: z.enum(["dish", "drink"]).default("dish"),
visibility: z.enum(["private", "unlisted", "public"]).default("private"), visibility: z.enum(["private", "unlisted", "public"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).max(1440).optional(), prepMins: z.number().int().min(0).max(1440).optional(),
@@ -69,9 +70,11 @@ export async function GET(req: NextRequest) {
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100); const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100);
const offset = parseInt(searchParams.get("offset") ?? "0"); const offset = parseInt(searchParams.get("offset") ?? "0");
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | null; const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | null;
const recipeType = searchParams.get("recipeType") as "dish" | "drink" | null;
const conditions = [eq(recipes.authorId, session!.user.id)]; const conditions = [eq(recipes.authorId, session!.user.id)];
if (visibility) conditions.push(eq(recipes.visibility, visibility)); if (visibility) conditions.push(eq(recipes.visibility, visibility));
if (recipeType) conditions.push(eq(recipes.recipeType, recipeType));
const rows = await db const rows = await db
.select() .select()
@@ -118,6 +121,7 @@ export async function POST(req: NextRequest) {
title: data.title, title: data.title,
description: data.description, description: data.description,
baseServings: data.baseServings, baseServings: data.baseServings,
recipeType: data.recipeType,
visibility: data.visibility, visibility: data.visibility,
difficulty: data.difficulty, difficulty: data.difficulty,
prepMins: data.prepMins, prepMins: data.prepMins,
+72 -47
View File
@@ -66,6 +66,7 @@ type RecipeFormProps = {
title?: string; title?: string;
description?: string; description?: string;
baseServings?: number; baseServings?: number;
recipeType?: "dish" | "drink";
visibility?: "private" | "unlisted" | "public"; visibility?: "private" | "unlisted" | "public";
difficulty?: "easy" | "medium" | "hard" | null; difficulty?: "easy" | "medium" | "hard" | null;
prepMins?: number | null; prepMins?: number | null;
@@ -103,6 +104,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const [title, setTitle] = useState(defaultValues?.title ?? ""); const [title, setTitle] = useState(defaultValues?.title ?? "");
const [description, setDescription] = useState(defaultValues?.description ?? ""); const [description, setDescription] = useState(defaultValues?.description ?? "");
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4)); const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
const [recipeType, setRecipeType] = useState<"dish" | "drink">(defaultValues?.recipeType ?? "dish");
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public">(defaultValues?.visibility ?? "private"); const [visibility, setVisibility] = useState<"private" | "unlisted" | "public">(defaultValues?.visibility ?? "private");
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? ""); const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? "")); const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
@@ -150,6 +152,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
photos, photos,
isBatchCook, isBatchCook,
dishes, dishes,
recipeType,
]); ]);
// Browser-level guard: warn on tab close / reload / external navigation while dirty. // Browser-level guard: warn on tab close / reload / external navigation while dirty.
@@ -301,6 +304,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
title: title.trim(), title: title.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
baseServings: parseInt(baseServings) || 4, baseServings: parseInt(baseServings) || 4,
recipeType,
visibility, visibility,
difficulty: difficulty || undefined, difficulty: difficulty || undefined,
prepMins: prepMins ? parseInt(prepMins) : undefined, prepMins: prepMins ? parseInt(prepMins) : undefined,
@@ -365,9 +369,22 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
/> />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="space-y-2">
<Label htmlFor="recipeType">{t("recipeType")}</Label>
<select
id="recipeType"
value={recipeType}
onChange={(e) => setRecipeType(e.target.value as "dish" | "drink")}
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="dish">{t("recipeTypeDish")}</option>
<option value="drink">{t("recipeTypeDrink")}</option>
</select>
</div>
<div className={cn("grid grid-cols-2 gap-4", recipeType === "dish" && "md:grid-cols-4")}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="servings">{t("servings")}</Label> <Label htmlFor="servings">{recipeType === "drink" ? t("servingsDrink") : t("servings")}</Label>
<Input <Input
id="servings" id="servings"
type="number" type="number"
@@ -377,42 +394,46 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
onChange={(e) => setBaseServings(e.target.value)} onChange={(e) => setBaseServings(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> {recipeType === "dish" && (
<Label htmlFor="prep">{t("prepMins")}</Label> <>
<Input <div className="space-y-2">
id="prep" <Label htmlFor="prep">{t("prepMins")}</Label>
type="number" <Input
min={0} id="prep"
value={prepMins} type="number"
onChange={(e) => setPrepMins(e.target.value)} min={0}
placeholder="0" value={prepMins}
/> onChange={(e) => setPrepMins(e.target.value)}
</div> placeholder="0"
<div className="space-y-2"> />
<Label htmlFor="cook">{t("cookMins")}</Label> </div>
<Input <div className="space-y-2">
id="cook" <Label htmlFor="cook">{t("cookMins")}</Label>
type="number" <Input
min={0} id="cook"
value={cookMins} type="number"
onChange={(e) => setCookMins(e.target.value)} min={0}
placeholder="0" value={cookMins}
/> onChange={(e) => setCookMins(e.target.value)}
</div> placeholder="0"
<div className="space-y-2"> />
<Label htmlFor="difficulty">{t("difficulty")}</Label> </div>
<select <div className="space-y-2">
id="difficulty" <Label htmlFor="difficulty">{t("difficulty")}</Label>
value={difficulty} <select
onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")} id="difficulty"
className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" value={difficulty}
> onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")}
<option value=""></option> className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
<option value="easy">{t_recipe("difficulty.easy")}</option> >
<option value="medium">{t_recipe("difficulty.medium")}</option> <option value=""></option>
<option value="hard">{t_recipe("difficulty.hard")}</option> <option value="easy">{t_recipe("difficulty.easy")}</option>
</select> <option value="medium">{t_recipe("difficulty.medium")}</option>
</div> <option value="hard">{t_recipe("difficulty.hard")}</option>
</select>
</div>
</>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -477,16 +498,20 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} /> <DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
</div> </div>
<Separator /> {recipeType === "dish" && (
<>
<Separator />
{/* Batch cooking */} {/* Batch cooking */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch id="batch-cook" checked={isBatchCook} onCheckedChange={setIsBatchCook} /> <Switch id="batch-cook" checked={isBatchCook} onCheckedChange={setIsBatchCook} />
<Label htmlFor="batch-cook" className="cursor-pointer">{t("batchCookToggle")}</Label> <Label htmlFor="batch-cook" className="cursor-pointer">{t("batchCookToggle")}</Label>
</div> </div>
<p className="text-xs text-muted-foreground">{t("batchCookToggleHelp")}</p> <p className="text-xs text-muted-foreground">{t("batchCookToggleHelp")}</p>
</div> </div>
</>
)}
<Separator /> <Separator />
+26 -2
View File
@@ -71,6 +71,12 @@ const BATCH_COOK_KEYS: Record<string, string> = {
"0": "batchCookExclude", "0": "batchCookExclude",
}; };
const RECIPE_TYPE_KEYS: Record<string, string> = {
"": "all",
dish: "dish",
drink: "drink",
};
export function RecipesHeader({ export function RecipesHeader({
count, count,
initialQuery = "", initialQuery = "",
@@ -79,6 +85,7 @@ export function RecipesHeader({
initialDifficulty = "", initialDifficulty = "",
initialTag = "", initialTag = "",
initialBatchCook = "", initialBatchCook = "",
initialRecipeType = "",
}: { }: {
count: number; count: number;
initialQuery?: string; initialQuery?: string;
@@ -87,6 +94,7 @@ export function RecipesHeader({
initialDifficulty?: string; initialDifficulty?: string;
initialTag?: string; initialTag?: string;
initialBatchCook?: string; initialBatchCook?: string;
initialRecipeType?: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -106,6 +114,7 @@ export function RecipesHeader({
difficulty: initialDifficulty, difficulty: initialDifficulty,
tag: initialTag, tag: initialTag,
batchCook: initialBatchCook, batchCook: initialBatchCook,
recipeType: initialRecipeType,
...overrides, ...overrides,
}; };
if (current.q?.trim()) params.set("q", current.q.trim()); if (current.q?.trim()) params.set("q", current.q.trim());
@@ -114,6 +123,7 @@ export function RecipesHeader({
if (current.difficulty) params.set("difficulty", current.difficulty); if (current.difficulty) params.set("difficulty", current.difficulty);
if (current.tag?.trim()) params.set("tag", current.tag.trim()); if (current.tag?.trim()) params.set("tag", current.tag.trim());
if (current.batchCook) params.set("batchCook", current.batchCook); if (current.batchCook) params.set("batchCook", current.batchCook);
if (current.recipeType) params.set("recipeType", current.recipeType);
startTransition(() => router.push(`${pathname}?${params.toString()}`)); startTransition(() => router.push(`${pathname}?${params.toString()}`));
} }
@@ -122,7 +132,7 @@ export function RecipesHeader({
pushParams({ q: value }); pushParams({ q: value });
} }
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag, initialBatchCook].filter(Boolean).length; const activeFilterCount = [initialVisibility, initialDifficulty, initialTag, initialBatchCook, initialRecipeType].filter(Boolean).length;
const sortChanged = initialSort !== "updated_desc"; const sortChanged = initialSort !== "updated_desc";
return ( return (
@@ -270,12 +280,26 @@ export function RecipesHeader({
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>{t("recipeTypeLabel")}</DropdownMenuLabel>
{Object.entries(RECIPE_TYPE_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
closeOnClick={false}
onClick={() => pushParams({ recipeType: value })}
className={cn(initialRecipeType === value && "font-medium text-primary")}
>
{value === "" ? t(key) : tRecipe(`recipeType.${key}`)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 && ( {activeFilterCount > 0 && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "", batchCook: "" })} onClick={() => pushParams({ visibility: "", difficulty: "", tag: "", batchCook: "", recipeType: "" })}
className="text-muted-foreground" className="text-muted-foreground"
> >
<X className="h-3 w-3 mr-1.5" /> {t("clearFilters")} <X className="h-3 w-3 mr-1.5" /> {t("clearFilters")}
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.30.1"; export const APP_VERSION = "0.31.0";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.31.0",
date: "2026-07-14 16:35",
added: [
"**Recipes can now be marked as a Drink/cocktail** instead of a dish — the recipe form hides irrelevant food-only fields (prep/cook time, difficulty, batch cooking) for drinks, and you can filter your recipe list by type. Drink recipes also no longer show the food/drink pairing suggestion buttons on their own page.",
],
},
{ {
version: "0.30.1", version: "0.30.1",
date: "2026-07-14 16:20", date: "2026-07-14 16:20",
+5 -3
View File
@@ -49,7 +49,7 @@ export function generateOpenApiSpec(): object {
const RecipeRef = registry.register("Recipe", z.object({ const RecipeRef = registry.register("Recipe", z.object({
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(), id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
baseServings: z.number().int(), visibility: z.enum(["private", "unlisted", "public"]), baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(), tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
@@ -62,7 +62,7 @@ export function generateOpenApiSpec(): object {
const RecipeSummaryRef = registry.register("RecipeSummary", z.object({ const RecipeSummaryRef = registry.register("RecipeSummary", z.object({
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(), id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
baseServings: z.number().int(), visibility: z.enum(["private", "unlisted", "public"]), baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(), tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
@@ -75,6 +75,7 @@ export function generateOpenApiSpec(): object {
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4), baseServings: z.number().int().min(1).max(100).default(4),
recipeType: z.enum(["dish", "drink"]).default("dish"),
visibility: z.enum(["private", "unlisted", "public"]).default("private"), visibility: z.enum(["private", "unlisted", "public"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).max(1440).optional(), prepMins: z.number().int().min(0).max(1440).optional(),
@@ -116,6 +117,7 @@ export function generateOpenApiSpec(): object {
title: z.string().min(1).max(200).optional(), title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(), description: z.string().max(2000).nullable().optional(),
baseServings: z.number().int().min(1).max(100).optional(), baseServings: z.number().int().min(1).max(100).optional(),
recipeType: z.enum(["dish", "drink"]).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(), visibility: z.enum(["private", "unlisted", "public"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(), difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(), prepMins: z.number().int().min(0).max(1440).nullable().optional(),
@@ -232,7 +234,7 @@ export function generateOpenApiSpec(): object {
const idParam = z.object({ id: z.string() }); const idParam = z.object({ id: z.string() });
const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }); const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") });
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
+9
View File
@@ -232,6 +232,10 @@
"medium": "Medium", "medium": "Medium",
"hard": "Hard" "hard": "Hard"
}, },
"recipeType": {
"dish": "Dish",
"drink": "Drink"
},
"dietary": { "dietary": {
"vegan": "Vegan", "vegan": "Vegan",
"vegetarian": "Vegetarian", "vegetarian": "Vegetarian",
@@ -664,6 +668,7 @@
"batchCookLabel": "Batch cooking", "batchCookLabel": "Batch cooking",
"batchCookOnly": "Batch cooking only", "batchCookOnly": "Batch cooking only",
"batchCookExclude": "Hide batch cooking", "batchCookExclude": "Hide batch cooking",
"recipeTypeLabel": "Type",
"clearFilters": "Clear filters", "clearFilters": "Clear filters",
"tabMine": "My Recipes", "tabMine": "My Recipes",
"tabFavorites": "Favorites", "tabFavorites": "Favorites",
@@ -689,7 +694,11 @@
"titlePlaceholder": "My amazing recipe", "titlePlaceholder": "My amazing recipe",
"descriptionLabel": "Description", "descriptionLabel": "Description",
"descriptionPlaceholder": "What makes this recipe special…", "descriptionPlaceholder": "What makes this recipe special…",
"recipeType": "Type",
"recipeTypeDish": "Dish",
"recipeTypeDrink": "Drink / cocktail",
"servings": "Servings", "servings": "Servings",
"servingsDrink": "Makes (servings)",
"prepMins": "Prep (min)", "prepMins": "Prep (min)",
"cookMins": "Cook (min)", "cookMins": "Cook (min)",
"difficulty": "Difficulty", "difficulty": "Difficulty",
+9
View File
@@ -232,6 +232,10 @@
"medium": "Moyen", "medium": "Moyen",
"hard": "Difficile" "hard": "Difficile"
}, },
"recipeType": {
"dish": "Plat",
"drink": "Boisson"
},
"dietary": { "dietary": {
"vegan": "Végétalien", "vegan": "Végétalien",
"vegetarian": "Végétarien", "vegetarian": "Végétarien",
@@ -655,6 +659,7 @@
"batchCookLabel": "Batch cooking", "batchCookLabel": "Batch cooking",
"batchCookOnly": "Batch cooking uniquement", "batchCookOnly": "Batch cooking uniquement",
"batchCookExclude": "Masquer le batch cooking", "batchCookExclude": "Masquer le batch cooking",
"recipeTypeLabel": "Type",
"clearFilters": "Effacer les filtres", "clearFilters": "Effacer les filtres",
"tabMine": "Mes recettes", "tabMine": "Mes recettes",
"tabFavorites": "Favoris", "tabFavorites": "Favoris",
@@ -680,7 +685,11 @@
"titlePlaceholder": "Ma super recette", "titlePlaceholder": "Ma super recette",
"descriptionLabel": "Description", "descriptionLabel": "Description",
"descriptionPlaceholder": "Ce qui rend cette recette spéciale…", "descriptionPlaceholder": "Ce qui rend cette recette spéciale…",
"recipeType": "Type",
"recipeTypeDish": "Plat",
"recipeTypeDrink": "Boisson / cocktail",
"servings": "Portions", "servings": "Portions",
"servingsDrink": "Quantité (portions)",
"prepMins": "Prép. (min)", "prepMins": "Prép. (min)",
"cookMins": "Cuisson (min)", "cookMins": "Cuisson (min)",
"difficulty": "Difficulté", "difficulty": "Difficulté",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.30.1", "version": "0.31.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.30.1", "version": "0.31.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",
@@ -0,0 +1,3 @@
CREATE TYPE "public"."recipe_type" AS ENUM('dish', 'drink');--> statement-breakpoint
ALTER TABLE "recipes" ADD COLUMN "recipe_type" "recipe_type" DEFAULT 'dish' NOT NULL;--> statement-breakpoint
CREATE INDEX "recipes_type_idx" ON "recipes" USING btree ("recipe_type");
File diff suppressed because it is too large Load Diff
@@ -274,6 +274,13 @@
"when": 1784014063004, "when": 1784014063004,
"tag": "0038_peaceful_norrin_radd", "tag": "0038_peaceful_norrin_radd",
"breakpoints": true "breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1784030221865,
"tag": "0039_sudden_franklin_storm",
"breakpoints": true
} }
] ]
} }
+3
View File
@@ -15,6 +15,7 @@ import { users } from "./users";
export const visibilityEnum = pgEnum("visibility", ["private", "unlisted", "public"]); export const visibilityEnum = pgEnum("visibility", ["private", "unlisted", "public"]);
export const difficultyEnum = pgEnum("difficulty", ["easy", "medium", "hard"]); export const difficultyEnum = pgEnum("difficulty", ["easy", "medium", "hard"]);
export const recipeTypeEnum = pgEnum("recipe_type", ["dish", "drink"]);
export type DietaryTags = { export type DietaryTags = {
vegan?: boolean; vegan?: boolean;
@@ -32,6 +33,7 @@ export const recipes = pgTable("recipes", {
title: text("title").notNull(), title: text("title").notNull(),
description: text("description"), description: text("description"),
baseServings: integer("base_servings").notNull().default(4), baseServings: integer("base_servings").notNull().default(4),
recipeType: recipeTypeEnum("recipe_type").notNull().default("dish"),
visibility: visibilityEnum("visibility").notNull().default("private"), visibility: visibilityEnum("visibility").notNull().default("private"),
sourceUrl: text("source_url"), sourceUrl: text("source_url"),
aiGenerated: boolean("ai_generated").notNull().default(false), aiGenerated: boolean("ai_generated").notNull().default(false),
@@ -53,6 +55,7 @@ export const recipes = pgTable("recipes", {
index("recipes_visibility_idx").on(t.visibility), index("recipes_visibility_idx").on(t.visibility),
index("recipes_dietary_tags_gin").using("gin", t.dietaryTags), index("recipes_dietary_tags_gin").using("gin", t.dietaryTags),
index("recipes_batch_cook_idx").on(t.isBatchCook), index("recipes_batch_cook_idx").on(t.isBatchCook),
index("recipes_type_idx").on(t.recipeType),
]); ]);
export const ingredients = pgTable("ingredients", { export const ingredients = pgTable("ingredients", {