Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Directory /var/www/html/project2>
AllowOverride Indexes
</Directory>
```
4 changes: 4 additions & 0 deletions client/public/.htaccess
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion client/src/api/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
8 changes: 2 additions & 6 deletions client/src/components/RecipeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand Down Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions client/src/components/RecipeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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" }}
/>
Expand Down
49 changes: 49 additions & 0 deletions client/src/hooks/useDebounce.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 13 additions & 0 deletions client/src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";

// Returns the value once it has stopped changing for `delay` ms.
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);

return debounced;
}
39 changes: 39 additions & 0 deletions client/src/utils/ingredients.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
48 changes: 48 additions & 0 deletions client/src/utils/ingredients.ts
Original file line number Diff line number Diff line change
@@ -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;
}