From de793712b765b39dbcc063545b1d07bb586a1a51 Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Fri, 16 Oct 2026 17:08:04 +0200 Subject: [PATCH 1/4] fix: use globalThis in fetch mock so the client builds (#7) * tsc -b failed on 'global', which is Node only and unknown to the browser tsconfig --- client/src/api/queries.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/api/queries.test.ts b/client/src/api/queries.test.ts index 0e2d047..746135b 100644 --- a/client/src/api/queries.test.ts +++ b/client/src/api/queries.test.ts @@ -3,7 +3,7 @@ import { fetchRecipes } from "./queries"; // Mock the global fetch function const globalFetchMock = vi.fn(); -global.fetch = globalFetchMock; +globalThis.fetch = globalFetchMock as unknown as typeof fetch; describe("fetchRecipes", () => { beforeEach(() => { From 9b86da2cf88020a5d25b225cb49048221eb979f5 Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Fri, 16 Oct 2026 17:08:31 +0200 Subject: [PATCH 2/4] fix: fall back to index.html so recipe pages work on reload (#8) * Add .htaccess with FallbackResource for /project2/ * Document the Apache AllowOverride setting needed on the VM --- README.md | 12 ++++++++++++ client/public/.htaccess | 4 ++++ 2 files changed, 16 insertions(+) create mode 100644 client/public/.htaccess diff --git a/README.md b/README.md index 691195a..c4449fc 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,15 @@ npm run dev ``` Prettier and ESLint also run automatically on staged files when you commit (husky + lint-staged). + +## Deployment + +The client build in `client/dist` is served by Apache under `/project2/`. `client/public/.htaccess` tells Apache to fall back to `index.html` for unknown paths, so recipe pages like `/project2/recipe/2` also work on reload and from shared links. + +Apache only reads `.htaccess` if overrides are allowed for the folder it serves `/project2/` from. If reloading a recipe page still gives a 404, add this to the site config on the VM (adjust the path to that folder) and reload Apache: + +```apache + + AllowOverride Indexes + +``` diff --git a/client/public/.htaccess b/client/public/.htaccess new file mode 100644 index 0000000..91b2ba4 --- /dev/null +++ b/client/public/.htaccess @@ -0,0 +1,4 @@ +# Send unknown paths under /project2/ to index.html so React Router can handle them. +# Without this, reloading /project2/recipe/2 gives a 404 because no such file exists. +# Apache only reads this file if AllowOverride allows it for the web root (see README). +FallbackResource /project2/index.html From 17bbf2310abbced28d2869758227163c59329826 Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Fri, 16 Oct 2026 17:09:41 +0200 Subject: [PATCH 3/4] fix: debounce search so it does not call the API on every keystroke (#9) * Add useDebounce hook with tests * Search field keeps its own state and updates the URL 300 ms after typing stops --- client/src/components/RecipeList.tsx | 31 ++++++++++++++++-- client/src/hooks/useDebounce.test.ts | 49 ++++++++++++++++++++++++++++ client/src/hooks/useDebounce.ts | 13 ++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 client/src/hooks/useDebounce.test.ts create mode 100644 client/src/hooks/useDebounce.ts diff --git a/client/src/components/RecipeList.tsx b/client/src/components/RecipeList.tsx index 1cda2c6..31324d0 100644 --- a/client/src/components/RecipeList.tsx +++ b/client/src/components/RecipeList.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useTransition, useCallback } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { fetchRecipes } from "../api/queries"; +import { useDebounce } from "../hooks/useDebounce"; import type { Recipe, NumericSortField, SortOrder } from "../api/types"; @@ -166,6 +167,32 @@ export function RecipeList() { }; }, [hasMore, offset, loadRecipes]); + // The field updates on every keystroke, but the URL (and so the API call) + // only changes once the user has stopped typing for 300 ms. + const [searchInput, setSearchInput] = useState(search); + const debouncedSearch = useDebounce(searchInput, 300); + const searchRef = useRef(search); + + useEffect(() => { + searchRef.current = search; + }, [search]); + + useEffect(() => { + if (debouncedSearch === searchRef.current) return; + + startTransition(() => { + setSearchParams((prev) => { + if (debouncedSearch.trim()) { + prev.set("search", debouncedSearch); + } else { + prev.delete("search"); + } + + return prev; + }); + }); + }, [debouncedSearch, setSearchParams]); + const updateParam = (key: string, value: string) => { startTransition(() => { setSearchParams((prev) => { @@ -194,9 +221,9 @@ export function RecipeList() { placeholder="Search recipes..." - value={search} + value={searchInput} - onChange={(e) => updateParam("search", e.target.value)} + onChange={(e) => setSearchInput(e.target.value)} style={{ padding: "8px", flex: "1 1 200px" }} /> diff --git a/client/src/hooks/useDebounce.test.ts b/client/src/hooks/useDebounce.test.ts new file mode 100644 index 0000000..456df74 --- /dev/null +++ b/client/src/hooks/useDebounce.test.ts @@ -0,0 +1,49 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDebounce } from "./useDebounce"; + +describe("useDebounce", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("only returns the new value after the delay", () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), { + initialProps: { value: "a" }, + }); + + rerender({ value: "ap" }); + rerender({ value: "app" }); + expect(result.current).toBe("a"); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe("app"); + }); + + it("restarts the timer while the value keeps changing", () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), { + initialProps: { value: "" }, + }); + + rerender({ value: "p" }); + act(() => { + vi.advanceTimersByTime(200); + }); + rerender({ value: "pa" }); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(result.current).toBe(""); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(result.current).toBe("pa"); + }); +}); diff --git a/client/src/hooks/useDebounce.ts b/client/src/hooks/useDebounce.ts new file mode 100644 index 0000000..5e55aad --- /dev/null +++ b/client/src/hooks/useDebounce.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +// Returns the value once it has stopped changing for `delay` ms. +export function useDebounce(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} From 787ce51a466b740e0cbf66f0096a66316525c56d Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Fri, 16 Oct 2026 17:10:59 +0200 Subject: [PATCH 4/4] fix: only split ingredients where a new one starts (#10) * Add splitIngredients that keeps notes like 'or as needed' with their ingredient * A comma starts a new item when the next part has a quantity or is a no-quantity ingredient like salt * Add tests based on real recipes from the dataset --- client/src/components/RecipeDetail.tsx | 8 ++--- client/src/utils/ingredients.test.ts | 39 +++++++++++++++++++++ client/src/utils/ingredients.ts | 48 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 client/src/utils/ingredients.test.ts create mode 100644 client/src/utils/ingredients.ts diff --git a/client/src/components/RecipeDetail.tsx b/client/src/components/RecipeDetail.tsx index 743bd33..87f6b23 100644 --- a/client/src/components/RecipeDetail.tsx +++ b/client/src/components/RecipeDetail.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { Link, useParams } from "react-router-dom"; import { fetchRecipes } from "../api/queries"; import type { Recipe } from "../api/types"; +import { splitIngredients } from "../utils/ingredients"; export function RecipeDetail() { const { id } = useParams<{ id: string }>(); @@ -44,12 +45,7 @@ export function RecipeDetail() { ); } - const ingredientList = recipe.ingredients - ? recipe.ingredients - .split(",") - .map((i) => i.trim()) - .filter(Boolean) - : []; + const ingredientList = splitIngredients(recipe.ingredients); const directionList = recipe.directions ? recipe.directions diff --git a/client/src/utils/ingredients.test.ts b/client/src/utils/ingredients.test.ts new file mode 100644 index 0000000..a88f459 --- /dev/null +++ b/client/src/utils/ingredients.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { splitIngredients } from "./ingredients"; + +describe("splitIngredients", () => { + it("keeps notes after a comma with the ingredient they belong to", () => { + const text = + "8 small Granny Smith apples, or as needed, ½ cup unsalted butter, 1 (9 inch) double-crust pie pastry, thawed"; + + expect(splitIngredients(text)).toEqual([ + "8 small Granny Smith apples, or as needed", + "½ cup unsalted butter", + "1 (9 inch) double-crust pie pastry, thawed", + ]); + }); + + it("keeps descriptions that span several commas together", () => { + expect(splitIngredients("4 apples, peeled, cored, and sliced, ¾ cup water")).toEqual([ + "4 apples, peeled, cored, and sliced", + "¾ cup water", + ]); + }); + + it("starts a new item for ingredients listed without a quantity", () => { + expect( + splitIngredients( + "1 pinch dried thyme, salt and ground black pepper to taste, cooking spray", + ), + ).toEqual([ + "1 pinch dried thyme", + "salt and ground black pepper to taste", + "cooking spray", + ]); + }); + + it("returns an empty list for missing text", () => { + expect(splitIngredients(null)).toEqual([]); + expect(splitIngredients("")).toEqual([]); + }); +}); diff --git a/client/src/utils/ingredients.ts b/client/src/utils/ingredients.ts new file mode 100644 index 0000000..86cf81a --- /dev/null +++ b/client/src/utils/ingredients.ts @@ -0,0 +1,48 @@ +// The dataset stores all ingredients in one comma-separated string, but commas +// are also used inside a single ingredient ("8 apples, or as needed"). +// A comma only starts a new ingredient when the next part begins with a +// quantity, or with an ingredient that is usually listed without one. + +const QUANTITY = /^[\d½¼¾⅓⅔⅛⅜⅝⅞⅕⅖⅗⅘⅙⅚]/; + +const NO_QUANTITY_STARTS = new Set([ + "salt", + "sea", + "kosher", + "ground", + "freshly", + "pepper", + "cooking", + "water", + "ice", + "olive", + "vegetable", + "oil", + "aluminum", + "toothpicks", +]); + +function startsNewIngredient(part: string): boolean { + if (QUANTITY.test(part)) return true; + const firstWord = part.split(/\s+/)[0]?.toLowerCase() ?? ""; + return NO_QUANTITY_STARTS.has(firstWord); +} + +export function splitIngredients(text: string | null | undefined): string[] { + if (!text) return []; + + const items: string[] = []; + + for (const raw of text.split(",")) { + const part = raw.trim(); + if (!part) continue; + + if (items.length === 0 || startsNewIngredient(part)) { + items.push(part); + } else { + items[items.length - 1] += `, ${part}`; + } + } + + return items; +}