diff --git a/CHANGELOG.md b/CHANGELOG.md
index 698dbf0..7537a7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
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.75.0 — 2026-07-24 11:30
+
+### Added
+- Mark any recipe as cooked (not just batch-cook dishes) — log the date, servings made, and whether to deduct matching ingredients from your pantry. Log the same recipe cooked as many times as you like; the recipe page shows how many times and when you last cooked it.
+
+### Fixed
+- Auto-deduct pantry on cook now actually fires — both existing mark-cooked flows (batch-cook dishes, meal-plan entries) previously hardcoded it off despite the underlying logic working correctly.
+
## 0.74.0 — 2026-07-24 10:30
### Added
diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md
index fc02ad3..97c141f 100644
--- a/FEATURE_AUDIT.md
+++ b/FEATURE_AUDIT.md
@@ -68,7 +68,8 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
| Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` |
-| Auto-deduct pantry on cook | **API-only, dead in the UI** | The API logic is correct (name+unit matching, defaults `deductFromPantry: true`) — but both real UI callers (`batch-cook-dishes.tsx`, `meal-planner.tsx`'s mark-cooked flow) hardcode `deductFromPantry: false`. A home cook using the app today never triggers auto-deduction; it's only reachable via a direct API/API-key client. Previously documented as "Exists, one nuance," which overstated real-world behavior. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
+| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
+| Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) |
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` |
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
@@ -159,7 +160,7 @@ Ranked roughly by likely value:
9. **Offline coverage for shopping lists / meal plan** — currently recipe-viewing + mark-cooked only.
10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish).
11. **Followers-only recipes invisible on the author's own profile grid** (2026-07-24) — `u/[username]/page.tsx` only queries `visibility = "public"` for the grid, so a followers-only recipe shows up in a follower's feed but never on the profile it belongs to. Real bug, not a design choice.
-12. **Auto-deduct pantry on cook is dead in the UI** (2026-07-24) — correct, tested API logic that literally nothing in the product surfaces; both UI callers hardcode it off. Either wire it up (a real feature a home cook would want) or stop pretending it's a shipped feature.
+12. ~~Auto-deduct pantry on cook is dead in the UI~~ — closed 2026-07-24 (both UI callers now pass `true`; the new Mark Cooked dialog exposes it as a checkbox).
13. ~~Model Prefs picker is ungated~~ — closed 2026-07-24 (now behind BYOK access, hidden entirely for everyone else).
## Consumer-friendliness assessment — "is Epicure too tech-savvy?" (2026-07-24)
diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx
index 7b12248..fdafe05 100644
--- a/apps/web/app/(app)/recipes/[id]/page.tsx
+++ b/apps/web/app/(app)/recipes/[id]/page.tsx
@@ -31,6 +31,7 @@ import { ServingScaler } from "@/components/recipe/serving-scaler";
import { FavoriteButton } from "@/components/social/favorite-button";
import { RatingStars } from "@/components/social/rating-stars";
import { CookedItReview } from "@/components/social/cooked-it-review";
+import { MarkCookedSection } from "@/components/recipe/mark-cooked-section";
import { RecipeNotes } from "@/components/recipe/recipe-notes";
import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage";
@@ -127,6 +128,12 @@ export default async function RecipePage({ params }: Params) {
}
}
+ // Non-batch cook log — batch-cook recipes track this per-dish instead
+ // (dishCookedAtMap above), logged via BatchCookDishes, not this list.
+ const plainCookLog = dishCookLog.filter((l) => !l.batchDishId);
+ const cookCount = plainCookLog.length;
+ const lastCookedAt = plainCookLog[0]?.cookedAt.toISOString() ?? null;
+
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
const ratingCount = ratingData[0]?.total ?? 0;
const isFavorited = !!favoriteData;
@@ -492,6 +499,18 @@ export default async function RecipePage({ params }: Params) {
>
)}
+ {!recipe.isBatchCook && (
+ <>
+
+
+ >
+ )}
+
{recipe.visibility !== "private" && (
<>
diff --git a/apps/web/app/api/v1/recipes/[id]/cooked/route.ts b/apps/web/app/api/v1/recipes/[id]/cooked/route.ts
index 5534ef1..277f470 100644
--- a/apps/web/app/api/v1/recipes/[id]/cooked/route.ts
+++ b/apps/web/app/api/v1/recipes/[id]/cooked/route.ts
@@ -10,6 +10,9 @@ const Schema = z.object({
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
batchDishId: z.string().optional(),
+ /** ISO date (YYYY-MM-DD) or full datetime — lets a user log a past cook,
+ * not just "just now". Defaults to now when omitted. */
+ cookedAt: z.string().optional(),
});
export async function POST(req: NextRequest, { params }: Params) {
@@ -44,6 +47,8 @@ export async function POST(req: NextRequest, { params }: Params) {
isFirstBatchCook = !priorCook;
}
+ const cookedAt = data.cookedAt ? new Date(data.cookedAt) : new Date();
+
await db.insert(cookingHistory).values({
id: crypto.randomUUID(),
userId,
@@ -51,7 +56,7 @@ export async function POST(req: NextRequest, { params }: Params) {
batchDishId: data.batchDishId,
servings: data.servings,
notes: data.notes,
- cookedAt: new Date(),
+ cookedAt: isNaN(cookedAt.getTime()) ? new Date() : cookedAt,
});
if (data.deductFromPantry && (!data.batchDishId || isFirstBatchCook)) {
diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx
index 83c9e35..5f23bff 100644
--- a/apps/web/components/meal-plan/meal-planner.tsx
+++ b/apps/web/components/meal-plan/meal-planner.tsx
@@ -377,7 +377,7 @@ export function MealPlanner({
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ servings: entry.servings, deductFromPantry: false }),
+ body: JSON.stringify({ servings: entry.servings, deductFromPantry: true }),
});
if (!res.ok) {
toast.error(t("markCookedFailed"));
diff --git a/apps/web/components/recipe/batch-cook-dishes.tsx b/apps/web/components/recipe/batch-cook-dishes.tsx
index 6d4dc58..7b349a9 100644
--- a/apps/web/components/recipe/batch-cook-dishes.tsx
+++ b/apps/web/components/recipe/batch-cook-dishes.tsx
@@ -34,7 +34,7 @@ export function BatchCookDishes({ recipeId, dishes: initialDishes }: { recipeId:
async function markCooked(dishId: string) {
setMarkingId(dishId);
- const body = { batchDishId: dishId, deductFromPantry: false };
+ const body = { batchDishId: dishId, deductFromPantry: true };
try {
let res: Response;
try {
diff --git a/apps/web/components/recipe/mark-cooked-dialog.tsx b/apps/web/components/recipe/mark-cooked-dialog.tsx
new file mode 100644
index 0000000..076b32e
--- /dev/null
+++ b/apps/web/components/recipe/mark-cooked-dialog.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { cloneElement, isValidElement, useState, type ReactElement } from "react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { ChefHat } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+
+function todayIso(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+interface MarkCookedDialogProps {
+ recipeId: string;
+ baseServings: number;
+ batchDishId?: string;
+ trigger: React.ReactNode;
+ onLogged?: (cookedAt: string) => void;
+}
+
+/** Logs a cook event — date, servings, and whether to deduct matching
+ * ingredients from the pantry (default on). A recipe can be logged as
+ * cooked any number of times; each submission is a new row, never an
+ * update. */
+export function MarkCookedDialog({ recipeId, baseServings, batchDishId, trigger, onLogged }: MarkCookedDialogProps) {
+ const t = useTranslations("recipe");
+ const tCommon = useTranslations("common");
+ const [open, setOpen] = useState(false);
+ const [date, setDate] = useState(todayIso());
+ const [servings, setServings] = useState(baseServings);
+ const [deductFromPantry, setDeductFromPantry] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+
+ async function handleSubmit() {
+ setSubmitting(true);
+ try {
+ const res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ servings,
+ cookedAt: date,
+ deductFromPantry,
+ ...(batchDishId ? { batchDishId } : {}),
+ }),
+ });
+ if (!res.ok) throw new Error();
+ toast.success(t("markCookedSuccess"));
+ setOpen(false);
+ onLogged?.(date);
+ } catch {
+ toast.error(t("markCookedFailed"));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ const triggerElement = isValidElement(trigger)
+ ? cloneElement(trigger as ReactElement<{ onClick?: () => void }>, { onClick: () => setOpen(true) })
+ : trigger;
+
+ return (
+ <>
+ {triggerElement}
+
+ >
+ );
+}
diff --git a/apps/web/components/recipe/mark-cooked-section.tsx b/apps/web/components/recipe/mark-cooked-section.tsx
new file mode 100644
index 0000000..10a7c3f
--- /dev/null
+++ b/apps/web/components/recipe/mark-cooked-section.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { ChefHat } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useLocale } from "@/lib/i18n/provider";
+import { MarkCookedDialog } from "./mark-cooked-dialog";
+
+export function MarkCookedSection({
+ recipeId,
+ baseServings,
+ cookCount,
+ lastCookedAt,
+}: {
+ recipeId: string;
+ baseServings: number;
+ cookCount: number;
+ lastCookedAt: string | null;
+}) {
+ const t = useTranslations("recipe");
+ const router = useRouter();
+ const { locale } = useLocale();
+
+ return (
+
+
router.refresh()}
+ trigger={
+
+ }
+ />
+ {cookCount > 0 && (
+
+ {cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })}
+ {lastCookedAt && t("markCookedLast", { date: new Date(lastCookedAt).toLocaleDateString(locale, { month: "short", day: "numeric" }) })}
+
+ )}
+
+ );
+}
diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts
index c891434..9dabe1e 100644
--- a/apps/web/lib/changelog.ts
+++ b/apps/web/lib/changelog.ts
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
-export const APP_VERSION = "0.74.0";
+export const APP_VERSION = "0.75.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.75.0",
+ date: "2026-07-24 11:30",
+ added: [
+ "Mark any recipe as cooked (not just batch-cook dishes) — log the date, servings made, and whether to deduct matching ingredients from your pantry. Log the same recipe cooked as many times as you like; the recipe page shows how many times and when you last cooked it.",
+ ],
+ fixed: [
+ "Auto-deduct pantry on cook now actually fires — both existing mark-cooked flows (batch-cook dishes, meal-plan entries) previously hardcoded it off despite the underlying logic working correctly.",
+ ],
+ },
{
version: "0.74.0",
date: "2026-07-24 10:30",
diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts
index dc49660..ba3f686 100644
--- a/apps/web/lib/openapi.ts
+++ b/apps/web/lib/openapi.ts
@@ -290,7 +290,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "patch", path: "/api/v1/recipes/bulk", summary: "Bulk update visibility/tags (owned only)", security, request: { body: { content: { "application/json": { schema: BulkUpdateRecipesRef } }, required: true } }, responses: { 200: { description: "OK", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request / nothing to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/bulk/export", summary: "Export several recipes as one Markdown document", description: "Gated by the markdown_export tier feature flag.", security, request: { body: { content: { "application/json": { schema: BulkIdsRef } }, required: true } }, responses: { 200: { description: "Markdown", content: { "application/json": { schema: z.object({ markdown: z.string() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "None of the ids belong to you", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/fork", summary: "Fork (or duplicate your own) a recipe", description: "Rate-limited: 20 req/min. Source must be your own, public, or unlisted.", security, request: { params: idParam }, responses: { 201: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
- registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry.", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional() }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
+ registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/cooked", summary: "Log that you cooked this recipe", description: "Optionally deducts matching ingredients from your pantry. A recipe can be logged as cooked any number of times — each call inserts a new history row, never updates one. cookedAt lets you backdate a cook instead of only logging \"now\".", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ servings: z.number().int().min(1).max(1000).optional(), notes: z.string().max(2000).optional(), deductFromPantry: z.boolean().default(true), batchDishId: z.string().optional(), cookedAt: z.string().optional().describe("ISO date (YYYY-MM-DD) or datetime; defaults to now") }) } } } }, responses: { 201: { description: "Logged", content: { "application/json": { schema: z.object({ logged: z.boolean() }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/nutrition", summary: "Get cached nutrition estimate or manually-entered values", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data or null", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }).nullable(), manual: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/nutrition", summary: "Compute a fresh AI nutrition estimate (author only)", description: "Rate-limited: 10 req/min. Consumes AI quota. Overwrites any manually-entered nutrition and clears nutritionManual. Gated by the nutrition_estimation tier feature flag.", security, request: { params: idParam }, responses: { 200: { description: "Nutrition data", content: { "application/json": { schema: z.object({ nutrition: z.object({ perServing: NutritionInputRef }) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Feature disabled for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/notes", summary: "Get your own private note on a recipe", security, request: { params: idParam }, responses: { 200: { description: "Note or null", content: { "application/json": { schema: z.object({ note: RecipeNoteRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index d6ebfb9..fbb1504 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -147,6 +147,20 @@
"batchCookMarkSuccess": "Marked as cooked",
"batchCookMarkFailed": "Failed to mark as cooked",
"batchCookMarkQueuedOffline": "Saved offline — will sync when you're back online",
+ "markCookedButton": "Mark cooked",
+ "markCookedTitle": "Mark as cooked",
+ "markCookedDescription": "Log this cook — you can log the same recipe as cooked as many times as you like.",
+ "markCookedDateLabel": "Date",
+ "markCookedServingsLabel": "Servings made",
+ "markCookedDeductLabel": "Deduct from pantry",
+ "markCookedDeductDescription": "Remove matching ingredients you have on hand.",
+ "markCookedSubmit": "Mark cooked",
+ "markCookedSaving": "Saving…",
+ "markCookedSuccess": "Marked as cooked",
+ "markCookedFailed": "Failed to log this cook",
+ "markCookedCountSingular": "Cooked 1 time",
+ "markCookedCountPlural": "Cooked {count} times",
+ "markCookedLast": " · last {date}",
"batchCookExpiresOn": "Cooked — good until {date}",
"servings": "{count} servings",
"prep": "{mins}m prep",
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index fd68cca..655882f 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -147,6 +147,20 @@
"batchCookMarkSuccess": "Marqué comme cuisiné",
"batchCookMarkFailed": "Échec du marquage",
"batchCookMarkQueuedOffline": "Enregistré hors ligne — sera synchronisé au retour de la connexion",
+ "markCookedButton": "Marquer comme cuisiné",
+ "markCookedTitle": "Marquer comme cuisiné",
+ "markCookedDescription": "Enregistrez cette cuisson — vous pouvez marquer la même recette comme cuisinée autant de fois que vous le souhaitez.",
+ "markCookedDateLabel": "Date",
+ "markCookedServingsLabel": "Portions réalisées",
+ "markCookedDeductLabel": "Déduire du garde-manger",
+ "markCookedDeductDescription": "Retire les ingrédients correspondants que vous avez sous la main.",
+ "markCookedSubmit": "Marquer comme cuisiné",
+ "markCookedSaving": "Enregistrement…",
+ "markCookedSuccess": "Marqué comme cuisiné",
+ "markCookedFailed": "Échec de l'enregistrement",
+ "markCookedCountSingular": "Cuisiné 1 fois",
+ "markCookedCountPlural": "Cuisiné {count} fois",
+ "markCookedLast": " · dernière fois le {date}",
"batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}",
"servings": "{count} portions",
"prep": "{mins}m prép.",
diff --git a/apps/web/package.json b/apps/web/package.json
index f06a87f..847f801 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
- "version": "0.74.0",
+ "version": "0.75.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/package.json b/package.json
index 454976d..a8e0f70 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "epicure",
- "version": "0.74.0",
+ "version": "0.75.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",