From 7865244c3af7a05aa14f9579792c299a935f2f2e Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 13:21:41 +0100 Subject: [PATCH 1/9] =?UTF-8?q?feat(web):=20list=20recipes=20on=20home=20w?= =?UTF-8?q?ith=20thumbnails=20and=20links=20(PLAN=20=C2=A74.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch GET /recipes via listRecipes + createResource; loading, error, and empty states. - Recipe cards with thumbnail, title, summary; navigate to /recipes/:id with router Link. - Vitest mock for ./api listRecipes so tests run without a live API. - Tick PLAN §4.4. Made-with: Cursor --- PLAN.md | 2 +- apps/web/src/App.test.tsx | 11 +++- apps/web/src/pages/Home.tsx | 100 ++++++++++++++++++++++++++--- apps/web/src/pages/Page.css | 124 ++++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 10 deletions(-) diff --git a/PLAN.md b/PLAN.md index 34d5703..770b0a9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -28,7 +28,7 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O - [x] **4.1** Scaffold SolidJS + Vite + TypeScript in `apps/web`; **Biome** (lint + format + import organise); Vitest configured; exact dependency versions only; `README.md` for the app. - [x] **4.2** Generate or synchronise typed API usage from the OpenAPI spec (client/types) so API calls stay strictly typed. - [x] **4.3** App shell: router, layout, and global styles (clean, minimalist, bread-appropriate palette, responsive). -- [ ] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click. +- [x] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click. - [ ] **4.5** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id). - [ ] **4.6** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate. diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index f827428..eb8e8b2 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,7 +1,16 @@ import { render, screen } from '@solidjs/testing-library'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { App } from './App'; +vi.mock('./api', () => ({ + listRecipes: vi.fn().mockResolvedValue({ + data: [], + error: undefined, + request: new Request('http://127.0.0.1:8000/recipes'), + response: new Response(), + }), +})); + describe('App', () => { it('renders the main heading', () => { render(() => ); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 99fc9ea..41e8c78 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -1,11 +1,95 @@ +import { A } from '@solidjs/router'; import type { JSX } from 'solid-js'; +import { createResource, For, Show } from 'solid-js'; +import type { RecipeSummary } from '../api'; +import { listRecipes } from '../api'; import './Page.css'; -export const Home = (): JSX.Element => ( -
-

- Browse sourdough and yeasted recipes from the API. List and detail views - follow in the next milestones. -

-
-); +async function loadRecipes(): Promise { + const res = await listRecipes(); + if (res.error !== undefined) { + const err = res.error; + const message = + typeof err === 'object' && err !== null && 'message' in err + ? String((err as { message: unknown }).message) + : 'Could not load recipes.'; + throw new Error(message); + } + return res.data ?? []; +} + +export const Home = (): JSX.Element => { + const [recipes] = createResource(loadRecipes); + + return ( +
+

+ Recipes +

+

+ Browse sourdough and yeasted breads from the API. Select a recipe for + full ingredients and steps. +

+ + +

+ Loading recipes… +

+
+ + + {(err: unknown) => ( + + )} + + + + 0} + fallback={ +

+ No recipes to show yet. Try again later. +

+ } + > + +
+
+
+ ); +}; diff --git a/apps/web/src/pages/Page.css b/apps/web/src/pages/Page.css index 0c86750..62aac10 100644 --- a/apps/web/src/pages/Page.css +++ b/apps/web/src/pages/Page.css @@ -32,3 +32,127 @@ background: var(--code-bg); color: var(--text-h); } + +.home-lede { + margin-bottom: 1.25rem; +} + +.recipe-list-status, +.recipe-list-empty { + color: var(--text-muted); + margin: 0; +} + +.recipe-list-error { + color: var(--accent-hover); + margin: 0; + padding: 0.75rem 1rem; + border-radius: 8px; + border: 1px solid var(--accent-border); + background: var(--accent-bg); +} + +.recipe-grid { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.recipe-grid-item { + margin: 0; +} + +.recipe-card { + display: grid; + grid-template-columns: minmax(0, 120px) minmax(0, 1fr); + gap: 0.85rem; + align-items: stretch; + padding: 0.65rem; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--social-bg); + box-shadow: var(--shadow); + text-decoration: none; + color: inherit; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.recipe-card:hover { + border-color: var(--accent-border); + box-shadow: + rgba(45, 70, 85, 0.1) 0 12px 28px -8px, + rgba(40, 55, 65, 0.06) 0 4px 10px -4px; +} + +@media (prefers-color-scheme: dark) { + .recipe-card:hover { + box-shadow: + rgba(0, 0, 0, 0.35) 0 12px 28px -8px, + rgba(0, 0, 0, 0.2) 0 4px 10px -4px; + } +} + +.recipe-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.recipe-card-media { + border-radius: 6px; + overflow: hidden; + background: var(--border); + aspect-ratio: 4 / 3; + min-height: 0; +} + +.recipe-card-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.recipe-card-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + justify-content: center; +} + +.recipe-card-title { + font-family: var(--heading); + font-weight: 500; + font-size: 1.05rem; + color: var(--accent); + margin: 0; + letter-spacing: -0.02em; +} + +.recipe-card:hover .recipe-card-title { + color: var(--accent-hover); +} + +.recipe-card-summary { + margin: 0; + font-size: 0.92rem; + line-height: 1.45; + color: var(--text-muted); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@media (min-width: 480px) { + .recipe-card { + grid-template-columns: minmax(0, 140px) minmax(0, 1fr); + gap: 1rem; + padding: 0.75rem; + } +} From 7e6a43c9020129905630dea54eaee6d60301f598 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 16:44:52 +0100 Subject: [PATCH 2/9] feat(web): recipe detail, CORS, path alias, and Home tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add recipe detail page data loading and styles (PLAN §4.6); shared loaders - Enable API CORS for local Vite origins - Use @/ alias; Biome rules (arrows, explicit types, no default export) - Add Home.test.tsx with MemoryRouter; fix img dimensions for 4:3 cards - Update PLAN (§4.5 component library note); Cursor rule for path aliases Made-with: Cursor --- .cursor/rules/path-alias-typescript.mdc | 13 +++ PLAN.md | 5 +- apps/api/app/main.py | 12 +++ apps/web/biome.json | 13 ++- apps/web/src/App.test.tsx | 4 +- apps/web/src/App.tsx | 6 +- apps/web/src/api/client.ts | 2 +- apps/web/src/api/index.ts | 13 ++- apps/web/src/index.tsx | 4 +- apps/web/src/layout/AppShell.tsx | 2 +- apps/web/src/lib/loadRecipeDetail.ts | 19 ++++ apps/web/src/lib/loadRecipes.ts | 16 ++++ apps/web/src/pages/Home.test.tsx | 76 ++++++++++++++++ apps/web/src/pages/Home.tsx | 23 +---- apps/web/src/pages/Page.css | 94 +++++++++++++++++++ apps/web/src/pages/RecipePage.tsx | 116 ++++++++++++++++++++++-- apps/web/tsconfig.app.json | 4 + apps/web/vite.config.ts | 9 ++ 18 files changed, 386 insertions(+), 45 deletions(-) create mode 100644 .cursor/rules/path-alias-typescript.mdc create mode 100644 apps/web/src/lib/loadRecipeDetail.ts create mode 100644 apps/web/src/lib/loadRecipes.ts create mode 100644 apps/web/src/pages/Home.test.tsx diff --git a/.cursor/rules/path-alias-typescript.mdc b/.cursor/rules/path-alias-typescript.mdc new file mode 100644 index 0000000..f5e8958 --- /dev/null +++ b/.cursor/rules/path-alias-typescript.mdc @@ -0,0 +1,13 @@ +--- +description: Path alias @/ vs relative imports in apps/web TypeScript +globs: + - apps/web/**/*.ts + - apps/web/**/*.tsx +alwaysApply: false +--- + +# Path imports (`apps/web`) + +- **`@/…`** — Use for anything that would otherwise need **`../`** (imports from a parent directory). Configured in `tsconfig.app.json` and Vite. +- **`./…`** — **Allowed** for same-folder / colocated modules (e.g. `./Page.css` next to a page, `./App` from a test in the same directory). Do not require rewriting these to `@/`. +- **`../…`** — Disallowed by Biome `style/noRestrictedImports` (`../**`); use `@/` instead. diff --git a/PLAN.md b/PLAN.md index 770b0a9..ed371b4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -29,8 +29,9 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O - [x] **4.2** Generate or synchronise typed API usage from the OpenAPI spec (client/types) so API calls stay strictly typed. - [x] **4.3** App shell: router, layout, and global styles (clean, minimalist, bread-appropriate palette, responsive). - [x] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click. -- [ ] **4.5** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id). -- [ ] **4.6** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate. +- [ ] **4.5** Component library: evaluate options for SolidJS (e.g. **shadcn-solid** with Tailwind and Kobalte primitives vs smaller headless stacks); record the decision; add the chosen tooling and migrate or adopt components on at least one real screen so the pattern is established. +- [x] **4.6** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id). +- [ ] **4.7** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate. ## 5. Contract testing (Pact) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index d8aa204..c5184d2 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.responses import JSONResponse @@ -34,6 +35,17 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + @app.exception_handler(RecipeNotFoundError) def recipe_not_found_handler(_request: Request, _exc: RecipeNotFoundError) -> JSONResponse: diff --git a/apps/web/biome.json b/apps/web/biome.json index 4688bc1..0db319f 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -25,7 +25,18 @@ "useExplicitType": "error" }, "style": { - "noDefaultExport": "error" + "noDefaultExport": "error", + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["../**"], + "message": "Use the @/ path alias (see tsconfig paths) instead of ../ — same-folder imports like ./file are fine." + } + ] + } + } } } }, diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index eb8e8b2..a7e7ed2 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@solidjs/testing-library'; import { describe, expect, it, vi } from 'vitest'; -import { App } from './App'; +import { App } from '@/App'; -vi.mock('./api', () => ({ +vi.mock('@/api', () => ({ listRecipes: vi.fn().mockResolvedValue({ data: [], error: undefined, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index abb7cc4..645099c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,8 @@ import { Route, Router } from '@solidjs/router'; import type { JSX } from 'solid-js'; -import { AppShell } from './layout/AppShell'; -import { Home } from './pages/Home'; -import { RecipePage } from './pages/RecipePage'; +import { AppShell } from '@/layout/AppShell'; +import { Home } from '@/pages/Home'; +import { RecipePage } from '@/pages/RecipePage'; export const App = (): JSX.Element => ( diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 2c18b38..3828e2d 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -3,7 +3,7 @@ import { type ClientOptions, createClient, createConfig, -} from './generated/client'; +} from '@/api/generated/client'; function resolveBaseUrl(): string { const fromEnv = import.meta.env.VITE_API_BASE_URL; diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts index cb3b01e..ef6a6ca 100644 --- a/apps/web/src/api/index.ts +++ b/apps/web/src/api/index.ts @@ -1,12 +1,15 @@ -import { apiClient } from './client'; +import { apiClient } from '@/api/client'; import { getRecipeById as getRecipeByIdSdk, listRecipes as listRecipesSdk, type Options, -} from './generated/sdk.gen'; -import type { GetRecipeByIdData, ListRecipesData } from './generated/types.gen'; +} from '@/api/generated/sdk.gen'; +import type { + GetRecipeByIdData, + ListRecipesData, +} from '@/api/generated/types.gen'; -export { apiClient } from './client'; +export { apiClient } from '@/api/client'; export const listRecipes = ( options?: Options, @@ -24,4 +27,4 @@ export const getRecipeById = ( client: options?.client ?? apiClient, }); -export type * from './generated/types.gen'; +export type * from '@/api/generated/types.gen'; diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 6ce0956..e89da9f 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -1,8 +1,8 @@ /* @refresh reload */ import type { JSX } from 'solid-js'; import { render } from 'solid-js/web'; -import './index.css'; -import { App } from './App.tsx'; +import '@/index.css'; +import { App } from '@/App.tsx'; const root: HTMLElement | null = document.getElementById('root'); if (!root) { diff --git a/apps/web/src/layout/AppShell.tsx b/apps/web/src/layout/AppShell.tsx index 6a11f15..54a5d16 100644 --- a/apps/web/src/layout/AppShell.tsx +++ b/apps/web/src/layout/AppShell.tsx @@ -1,7 +1,7 @@ import type { RouteSectionProps } from '@solidjs/router'; import { A } from '@solidjs/router'; import type { JSX } from 'solid-js'; -import './AppShell.css'; +import '@/layout/AppShell.css'; export const AppShell = ({ children }: RouteSectionProps): JSX.Element => (
diff --git a/apps/web/src/lib/loadRecipeDetail.ts b/apps/web/src/lib/loadRecipeDetail.ts new file mode 100644 index 0000000..f4afef6 --- /dev/null +++ b/apps/web/src/lib/loadRecipeDetail.ts @@ -0,0 +1,19 @@ +import type { RecipeDetail } from '@/api'; +import { getRecipeById } from '@/api'; + +/** Fetches one recipe by id; throws on transport, 404, or other API errors. */ +export async function loadRecipeDetail(id: string): Promise { + const res = await getRecipeById({ path: { recipe_id: id } }); + if (res.error !== undefined) { + const err = res.error; + const message = + typeof err === 'object' && err !== null && 'message' in err + ? String((err as { message: unknown }).message) + : 'Could not load recipe.'; + throw new Error(message); + } + if (res.data === undefined) { + throw new Error('Recipe not found.'); + } + return res.data; +} diff --git a/apps/web/src/lib/loadRecipes.ts b/apps/web/src/lib/loadRecipes.ts new file mode 100644 index 0000000..3e1301f --- /dev/null +++ b/apps/web/src/lib/loadRecipes.ts @@ -0,0 +1,16 @@ +import type { RecipeSummary } from '@/api'; +import { listRecipes } from '@/api'; + +/** Fetches recipe summaries for the home list; throws on transport or API error. */ +export async function loadRecipes(): Promise { + const res = await listRecipes(); + if (res.error !== undefined) { + const err = res.error; + const message = + typeof err === 'object' && err !== null && 'message' in err + ? String((err as { message: unknown }).message) + : 'Could not load recipes.'; + throw new Error(message); + } + return res.data ?? []; +} diff --git a/apps/web/src/pages/Home.test.tsx b/apps/web/src/pages/Home.test.tsx new file mode 100644 index 0000000..6da76b8 --- /dev/null +++ b/apps/web/src/pages/Home.test.tsx @@ -0,0 +1,76 @@ +import { MemoryRouter, Route } from '@solidjs/router'; +import { render, screen } from '@solidjs/testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RecipeSummary } from '@/api'; +import { listRecipes } from '@/api'; +import { Home } from '@/pages/Home'; + +const renderHome = (): ReturnType => + render(() => ( + + + + )); + +vi.mock('@/api', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + listRecipes: vi.fn(), + }; +}); + +const listOk = ( + data: RecipeSummary[], +): { + data: RecipeSummary[]; + error: undefined; + request: Request; + response: Response; +} => ({ + data, + error: undefined, + request: new Request('http://127.0.0.1:8000/recipes'), + response: new Response(), +}); + +describe('Home', () => { + beforeEach(() => { + vi.mocked(listRecipes).mockReset(); + }); + + it('shows empty state when the API returns no recipes', async () => { + vi.mocked(listRecipes).mockResolvedValue(listOk([])); + renderHome(); + expect(await screen.findByText(/No recipes to show yet/i)).toBeTruthy(); + }); + + it('renders linked recipe cards when the API returns recipes', async () => { + vi.mocked(listRecipes).mockResolvedValue( + listOk([ + { + id: 'sourdough-1', + title: 'Seeded sourdough', + summary: 'Overnight ferment', + imageUrl: 'https://example.com/thumb.jpg', + }, + ]), + ); + renderHome(); + expect(await screen.findByText('Seeded sourdough')).toBeTruthy(); + const link = screen.getByRole('link', { name: /Seeded sourdough/i }); + expect(link.getAttribute('href')).toBe('/recipes/sourdough-1'); + }); + + it('shows an alert when the API returns an error payload', async () => { + vi.mocked(listRecipes).mockResolvedValue({ + data: undefined, + error: { message: 'Bad gateway' }, + request: new Request('http://127.0.0.1:8000/recipes'), + response: new Response(), + }); + renderHome(); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Bad gateway'); + }); +}); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 41e8c78..ce94736 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -1,22 +1,9 @@ import { A } from '@solidjs/router'; import type { JSX } from 'solid-js'; import { createResource, For, Show } from 'solid-js'; -import type { RecipeSummary } from '../api'; -import { listRecipes } from '../api'; -import './Page.css'; - -async function loadRecipes(): Promise { - const res = await listRecipes(); - if (res.error !== undefined) { - const err = res.error; - const message = - typeof err === 'object' && err !== null && 'message' in err - ? String((err as { message: unknown }).message) - : 'Could not load recipes.'; - throw new Error(message); - } - return res.data ?? []; -} +import type { RecipeSummary } from '@/api'; +import { loadRecipes } from '@/lib/loadRecipes'; +import '@/pages/Page.css'; export const Home = (): JSX.Element => { const [recipes] = createResource(loadRecipes); @@ -37,7 +24,7 @@ export const Home = (): JSX.Element => {

- + {(err: unknown) => (
-

- Recipe -

-

- Full ingredients, steps, and imagery for recipe{' '} - {params.id} arrive in PLAN §4.5. +

+ + +

+ Loading recipe… +

+
+ + + {(err: unknown) => ( + + )} + + + + + {(r: RecipeDetail) => ( + <> +
+

+ {r.title} +

+

{r.summary}

+ +

+ + + Prep: {r.prepTimeMinutes} min + + + + + Bake: {r.bakeTimeMinutes} min + + +

+
+
+ +
+ +
+ +
+

+ Ingredients +

+
    + + {(line: string) =>
  • {line}
  • } +
    +
+
+ +
+

+ Steps +

+
    + {(step: string) =>
  1. {step}
  2. }
    +
+
+ + )} +
+
+ ); }; diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index a61ed6b..2b089a2 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -14,6 +14,10 @@ "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, "jsx": "preserve", "jsxImportSource": "solid-js", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2c02513..ed700a7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,8 +1,17 @@ /// +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import solid from 'vite-plugin-solid'; +const __dirname: string = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, plugins: [solid()], test: { environment: 'jsdom', From 360ae7372976665cded02acbf82a27bb90b42ca8 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 16:50:51 +0100 Subject: [PATCH 3/9] chore(openapi): add yaml for Redocly lint; pin exact version Redoc requires the yaml package at runtime; pnpm does not hoist it. Aligns with REQUIREMENTS (no ^ or ~). Made-with: Cursor --- packages/openapi/package.json | 3 ++- pnpm-lock.yaml | 39 ++++++++++++++++++++++------------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 4c00887..04d5ac1 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@redocly/cli": "1.31.0", - "cross-env": "10.1.0" + "cross-env": "10.1.0", + "yaml": "2.8.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6834187..a19585d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,13 +43,13 @@ importers: version: 5.9.3 vite: specifier: 8.0.3 - version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1) + version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3) vite-plugin-solid: specifier: 2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)) + version: 2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)) vitest: specifier: 4.1.2 - version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)) + version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)) packages/openapi: devDependencies: @@ -59,6 +59,9 @@ importers: cross-env: specifier: 10.1.0 version: 10.1.0 + yaml: + specifier: 2.8.3 + version: 2.8.3 packages: @@ -2020,6 +2023,11 @@ packages: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -2647,13 +2655,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1))': + '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3) '@vitest/pretty-format@4.1.2': dependencies: @@ -3823,7 +3831,7 @@ snapshots: util-deprecate@1.0.2: {} - vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)): + vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)): dependencies: '@babel/core': 7.29.0 '@types/babel__core': 7.20.5 @@ -3831,14 +3839,14 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.12 solid-refresh: 0.6.3(solid-js@1.9.12) - vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1) - vitefu: 1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3) + vitefu: 1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)) optionalDependencies: '@testing-library/jest-dom': 6.9.1 transitivePeerDependencies: - supports-color - vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1): + vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -3849,18 +3857,19 @@ snapshots: '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 + yaml: 2.8.3 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - vitefu@1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)): + vitefu@1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)): optionalDependencies: - vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3) - vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)): + vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)) + '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 @@ -3877,7 +3886,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1) + vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -3947,6 +3956,8 @@ snapshots: yaml@1.10.3: {} + yaml@2.8.3: {} + yargs-parser@20.2.9: {} yargs@17.0.1: From 2a3a00d9aef9ce4c6ac97d0b9562087b74ae6ba4 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 16:55:43 +0100 Subject: [PATCH 4/9] refactor(web): use ./ imports where path stays within directory Prefer relative same-folder imports for api barrel, entrypoint, layout, and pages; keep @/ for imports that would require ../ (Biome rule). Made-with: Cursor --- apps/web/src/App.test.tsx | 2 +- apps/web/src/api/client.ts | 2 +- apps/web/src/api/index.ts | 13 +++++-------- apps/web/src/index.tsx | 4 ++-- apps/web/src/layout/AppShell.tsx | 2 +- apps/web/src/pages/Home.test.tsx | 2 +- apps/web/src/pages/Home.tsx | 2 +- apps/web/src/pages/RecipePage.tsx | 2 +- 8 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index a7e7ed2..097142b 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@solidjs/testing-library'; import { describe, expect, it, vi } from 'vitest'; -import { App } from '@/App'; +import { App } from './App'; vi.mock('@/api', () => ({ listRecipes: vi.fn().mockResolvedValue({ diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3828e2d..2c18b38 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -3,7 +3,7 @@ import { type ClientOptions, createClient, createConfig, -} from '@/api/generated/client'; +} from './generated/client'; function resolveBaseUrl(): string { const fromEnv = import.meta.env.VITE_API_BASE_URL; diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts index ef6a6ca..cb3b01e 100644 --- a/apps/web/src/api/index.ts +++ b/apps/web/src/api/index.ts @@ -1,15 +1,12 @@ -import { apiClient } from '@/api/client'; +import { apiClient } from './client'; import { getRecipeById as getRecipeByIdSdk, listRecipes as listRecipesSdk, type Options, -} from '@/api/generated/sdk.gen'; -import type { - GetRecipeByIdData, - ListRecipesData, -} from '@/api/generated/types.gen'; +} from './generated/sdk.gen'; +import type { GetRecipeByIdData, ListRecipesData } from './generated/types.gen'; -export { apiClient } from '@/api/client'; +export { apiClient } from './client'; export const listRecipes = ( options?: Options, @@ -27,4 +24,4 @@ export const getRecipeById = ( client: options?.client ?? apiClient, }); -export type * from '@/api/generated/types.gen'; +export type * from './generated/types.gen'; diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index e89da9f..72fe88a 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -1,8 +1,8 @@ /* @refresh reload */ import type { JSX } from 'solid-js'; import { render } from 'solid-js/web'; -import '@/index.css'; -import { App } from '@/App.tsx'; +import './index.css'; +import { App } from './App'; const root: HTMLElement | null = document.getElementById('root'); if (!root) { diff --git a/apps/web/src/layout/AppShell.tsx b/apps/web/src/layout/AppShell.tsx index 54a5d16..6a11f15 100644 --- a/apps/web/src/layout/AppShell.tsx +++ b/apps/web/src/layout/AppShell.tsx @@ -1,7 +1,7 @@ import type { RouteSectionProps } from '@solidjs/router'; import { A } from '@solidjs/router'; import type { JSX } from 'solid-js'; -import '@/layout/AppShell.css'; +import './AppShell.css'; export const AppShell = ({ children }: RouteSectionProps): JSX.Element => (
diff --git a/apps/web/src/pages/Home.test.tsx b/apps/web/src/pages/Home.test.tsx index 6da76b8..27d2e4b 100644 --- a/apps/web/src/pages/Home.test.tsx +++ b/apps/web/src/pages/Home.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@solidjs/testing-library'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RecipeSummary } from '@/api'; import { listRecipes } from '@/api'; -import { Home } from '@/pages/Home'; +import { Home } from './Home'; const renderHome = (): ReturnType => render(() => ( diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index ce94736..90fb1b7 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -3,7 +3,7 @@ import type { JSX } from 'solid-js'; import { createResource, For, Show } from 'solid-js'; import type { RecipeSummary } from '@/api'; import { loadRecipes } from '@/lib/loadRecipes'; -import '@/pages/Page.css'; +import './Page.css'; export const Home = (): JSX.Element => { const [recipes] = createResource(loadRecipes); diff --git a/apps/web/src/pages/RecipePage.tsx b/apps/web/src/pages/RecipePage.tsx index 01e146b..5319898 100644 --- a/apps/web/src/pages/RecipePage.tsx +++ b/apps/web/src/pages/RecipePage.tsx @@ -3,7 +3,7 @@ import type { JSX } from 'solid-js'; import { createResource, For, Show } from 'solid-js'; import type { RecipeDetail } from '@/api'; import { loadRecipeDetail } from '@/lib/loadRecipeDetail'; -import '@/pages/Page.css'; +import './Page.css'; export const RecipePage = (): JSX.Element => { const params = useParams<{ id: string }>(); From a1ee2c562467dbd779bf366f9af1ce118f52f63f Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 17:03:27 +0100 Subject: [PATCH 5/9] refactor(web): const arrow functions; lint script for declarations - Replace async function exports and resolveBaseUrl with const arrows - Run assert-no-function-declarations.mjs after Biome (useArrowFunction does not cover top-level function declarations) - Add Cursor rule documenting the convention Made-with: Cursor --- .cursor/rules/const-arrow-functions.mdc | 14 +++++ apps/web/package.json | 2 +- .../assert-no-function-declarations.mjs | 58 +++++++++++++++++++ apps/web/src/api/client.ts | 4 +- apps/web/src/lib/loadRecipeDetail.ts | 4 +- apps/web/src/lib/loadRecipes.ts | 4 +- 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 .cursor/rules/const-arrow-functions.mdc create mode 100644 apps/web/scripts/assert-no-function-declarations.mjs diff --git a/.cursor/rules/const-arrow-functions.mdc b/.cursor/rules/const-arrow-functions.mdc new file mode 100644 index 0000000..56a6857 --- /dev/null +++ b/.cursor/rules/const-arrow-functions.mdc @@ -0,0 +1,14 @@ +--- +description: Prefer const arrow functions; no function declarations in apps/web TS/TSX +globs: + - apps/web/**/*.ts + - apps/web/**/*.tsx +alwaysApply: false +--- + +# Function style (`apps/web`) + +- Use **`const name = (…) => { … }`** (or `async` arrows) for named functions, including exports. Do not use **`function name()`** declarations at module scope or nested in source files. +- **Biome** `complexity/useArrowFunction` rewrites **function expressions** to arrows; it does **not** flag top-level `function` declarations ([Biome discussion #7108](https://github.com/biomejs/biome/discussions/7108)). +- **`pnpm lint`** (and **`pnpm --filter web lint`**) also runs **`scripts/assert-no-function-declarations.mjs`**, which fails if a declaration line appears in hand-written code under `src/` (excluding `generated/`) or in `vite.config.ts` / `openapi-ts.config.ts`. +- Generated OpenAPI output under **`src/api/generated/`** is exempt from that script. diff --git a/apps/web/package.json b/apps/web/package.json index ff10ab7..401043c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "lint": "biome check .", + "lint": "biome check . && node scripts/assert-no-function-declarations.mjs", "lint:fix": "biome check --write .", "test": "vitest run", "test:watch": "vitest", diff --git a/apps/web/scripts/assert-no-function-declarations.mjs b/apps/web/scripts/assert-no-function-declarations.mjs new file mode 100644 index 0000000..30b042a --- /dev/null +++ b/apps/web/scripts/assert-no-function-declarations.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * Biome's useArrowFunction does not flag top-level `function` declarations (see https://github.com/biomejs/biome/discussions/7108). + * This check enforces `const name = (...) =>` for named functions in hand-written TS/TSX. + * Generated by Cursor. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const rootDir = path.join(import.meta.dirname, '..'); +/** Top-level / named `function` declarations (not `typeof x === 'function'`). */ +const declarationLine = + /^\s*(?:export\s+(?:default\s+)?(?:async\s+)?|async\s+)?function\s*\*?\s*(?:[$\w]|\()/; + +function walkTsFiles(dir, out) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (ent.name === 'generated') { + continue; + } + walkTsFiles(p, out); + } else if (/\.(ts|tsx)$/.test(ent.name)) { + out.push(p); + } + } +} + +const files = []; +walkTsFiles(path.join(rootDir, 'src'), files); + +for (const base of ['vite.config.ts', 'openapi-ts.config.ts']) { + const p = path.join(rootDir, base); + if (fs.existsSync(p)) { + files.push(p); + } +} + +let failed = false; +for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + const trimmed = line.trimStart(); + if (trimmed.startsWith('//') || trimmed.startsWith('*')) { + return; + } + if (declarationLine.test(line)) { + console.error( + `${path.relative(rootDir, file)}:${i + 1}: use \`const name = ...\` instead of a \`function\` declaration`, + ); + failed = true; + } + }); +} + +if (failed) { + process.exit(1); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 2c18b38..245b886 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -5,7 +5,7 @@ import { createConfig, } from './generated/client'; -function resolveBaseUrl(): string { +const resolveBaseUrl = (): string => { const fromEnv = import.meta.env.VITE_API_BASE_URL; if (fromEnv !== undefined && fromEnv !== '') { return fromEnv; @@ -14,7 +14,7 @@ function resolveBaseUrl(): string { return 'http://127.0.0.1:8000'; } return ''; -} +}; /** Shared fetch client for generated SDK calls (base URL from `VITE_API_BASE_URL`, or local API in dev). */ export const apiClient: Client = createClient( diff --git a/apps/web/src/lib/loadRecipeDetail.ts b/apps/web/src/lib/loadRecipeDetail.ts index f4afef6..1a7f2a5 100644 --- a/apps/web/src/lib/loadRecipeDetail.ts +++ b/apps/web/src/lib/loadRecipeDetail.ts @@ -2,7 +2,7 @@ import type { RecipeDetail } from '@/api'; import { getRecipeById } from '@/api'; /** Fetches one recipe by id; throws on transport, 404, or other API errors. */ -export async function loadRecipeDetail(id: string): Promise { +export const loadRecipeDetail = async (id: string): Promise => { const res = await getRecipeById({ path: { recipe_id: id } }); if (res.error !== undefined) { const err = res.error; @@ -16,4 +16,4 @@ export async function loadRecipeDetail(id: string): Promise { throw new Error('Recipe not found.'); } return res.data; -} +}; diff --git a/apps/web/src/lib/loadRecipes.ts b/apps/web/src/lib/loadRecipes.ts index 3e1301f..99cdbdd 100644 --- a/apps/web/src/lib/loadRecipes.ts +++ b/apps/web/src/lib/loadRecipes.ts @@ -2,7 +2,7 @@ import type { RecipeSummary } from '@/api'; import { listRecipes } from '@/api'; /** Fetches recipe summaries for the home list; throws on transport or API error. */ -export async function loadRecipes(): Promise { +export const loadRecipes = async (): Promise => { const res = await listRecipes(); if (res.error !== undefined) { const err = res.error; @@ -13,4 +13,4 @@ export async function loadRecipes(): Promise { throw new Error(message); } return res.data ?? []; -} +}; From de5a5063bc0bb1c57c4b1661ca8d67a39ceccc84 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 17:20:01 +0100 Subject: [PATCH 6/9] refactor(web): call generated SDK from pages; drop lib loaders - Home: createResource uses listRecipes({ throwOnError: true }) - RecipePage: getRecipeById inline; guard with !id - Remove loadRecipes/loadRecipeDetail; adjust Home error test for throws Made-with: Cursor --- apps/web/src/lib/loadRecipeDetail.ts | 19 ------------------- apps/web/src/lib/loadRecipes.ts | 16 ---------------- apps/web/src/pages/Home.test.tsx | 9 ++------- apps/web/src/pages/Home.tsx | 6 ++++-- apps/web/src/pages/RecipePage.tsx | 10 +++++++--- 5 files changed, 13 insertions(+), 47 deletions(-) delete mode 100644 apps/web/src/lib/loadRecipeDetail.ts delete mode 100644 apps/web/src/lib/loadRecipes.ts diff --git a/apps/web/src/lib/loadRecipeDetail.ts b/apps/web/src/lib/loadRecipeDetail.ts deleted file mode 100644 index 1a7f2a5..0000000 --- a/apps/web/src/lib/loadRecipeDetail.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { RecipeDetail } from '@/api'; -import { getRecipeById } from '@/api'; - -/** Fetches one recipe by id; throws on transport, 404, or other API errors. */ -export const loadRecipeDetail = async (id: string): Promise => { - const res = await getRecipeById({ path: { recipe_id: id } }); - if (res.error !== undefined) { - const err = res.error; - const message = - typeof err === 'object' && err !== null && 'message' in err - ? String((err as { message: unknown }).message) - : 'Could not load recipe.'; - throw new Error(message); - } - if (res.data === undefined) { - throw new Error('Recipe not found.'); - } - return res.data; -}; diff --git a/apps/web/src/lib/loadRecipes.ts b/apps/web/src/lib/loadRecipes.ts deleted file mode 100644 index 99cdbdd..0000000 --- a/apps/web/src/lib/loadRecipes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { RecipeSummary } from '@/api'; -import { listRecipes } from '@/api'; - -/** Fetches recipe summaries for the home list; throws on transport or API error. */ -export const loadRecipes = async (): Promise => { - const res = await listRecipes(); - if (res.error !== undefined) { - const err = res.error; - const message = - typeof err === 'object' && err !== null && 'message' in err - ? String((err as { message: unknown }).message) - : 'Could not load recipes.'; - throw new Error(message); - } - return res.data ?? []; -}; diff --git a/apps/web/src/pages/Home.test.tsx b/apps/web/src/pages/Home.test.tsx index 27d2e4b..34279b9 100644 --- a/apps/web/src/pages/Home.test.tsx +++ b/apps/web/src/pages/Home.test.tsx @@ -62,13 +62,8 @@ describe('Home', () => { expect(link.getAttribute('href')).toBe('/recipes/sourdough-1'); }); - it('shows an alert when the API returns an error payload', async () => { - vi.mocked(listRecipes).mockResolvedValue({ - data: undefined, - error: { message: 'Bad gateway' }, - request: new Request('http://127.0.0.1:8000/recipes'), - response: new Response(), - }); + it('shows an alert when the loader throws', async () => { + vi.mocked(listRecipes).mockRejectedValue(new Error('Bad gateway')); renderHome(); const alert = await screen.findByRole('alert'); expect(alert.textContent).toContain('Bad gateway'); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 90fb1b7..63ca020 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -2,11 +2,13 @@ import { A } from '@solidjs/router'; import type { JSX } from 'solid-js'; import { createResource, For, Show } from 'solid-js'; import type { RecipeSummary } from '@/api'; -import { loadRecipes } from '@/lib/loadRecipes'; +import { listRecipes } from '@/api'; import './Page.css'; export const Home = (): JSX.Element => { - const [recipes] = createResource(loadRecipes); + const [recipes] = createResource(() => + listRecipes({ throwOnError: true }).then(({ data }) => data ?? []), + ); return (
diff --git a/apps/web/src/pages/RecipePage.tsx b/apps/web/src/pages/RecipePage.tsx index 5319898..27372b7 100644 --- a/apps/web/src/pages/RecipePage.tsx +++ b/apps/web/src/pages/RecipePage.tsx @@ -2,7 +2,7 @@ import { A, useParams } from '@solidjs/router'; import type { JSX } from 'solid-js'; import { createResource, For, Show } from 'solid-js'; import type { RecipeDetail } from '@/api'; -import { loadRecipeDetail } from '@/lib/loadRecipeDetail'; +import { getRecipeById } from '@/api'; import './Page.css'; export const RecipePage = (): JSX.Element => { @@ -10,10 +10,14 @@ export const RecipePage = (): JSX.Element => { const [recipe] = createResource( () => params.id, async (id: string | undefined) => { - if (id === undefined || id === '') { + if (!id) { throw new Error('Missing recipe id.'); } - return loadRecipeDetail(id); + const { data } = await getRecipeById({ + path: { recipe_id: id }, + throwOnError: true, + }); + return data; }, ); From 202e9d604f1c625740991e8d0827771490f1efa7 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 17:24:30 +0100 Subject: [PATCH 7/9] refactor(web): destructure recipe props in Home and RecipePage Made-with: Cursor --- apps/web/src/pages/Home.tsx | 17 +++++++--------- apps/web/src/pages/RecipePage.tsx | 34 +++++++++++++++++++------------ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 63ca020..ff64467 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -45,17 +45,17 @@ export const Home = (): JSX.Element => { >
    - {(recipe: RecipeSummary) => ( + {({ id, imageUrl, title, summary }: RecipeSummary) => (
  • { />
    -

    - {recipe.title} +

    + {title}

    -

    {recipe.summary}

    +

    {summary}

  • diff --git a/apps/web/src/pages/RecipePage.tsx b/apps/web/src/pages/RecipePage.tsx index 27372b7..082a325 100644 --- a/apps/web/src/pages/RecipePage.tsx +++ b/apps/web/src/pages/RecipePage.tsx @@ -45,28 +45,36 @@ export const RecipePage = (): JSX.Element => { - {(r: RecipeDetail) => ( + {({ + title, + summary, + prepTimeMinutes, + bakeTimeMinutes, + imageUrlLarge, + ingredients, + steps, + }: RecipeDetail) => ( <>

    - {r.title} + {title}

    -

    {r.summary}

    +

    {summary}

    - + - Prep: {r.prepTimeMinutes} min + Prep: {prepTimeMinutes} min - + - Bake: {r.bakeTimeMinutes} min + Bake: {bakeTimeMinutes} min

    @@ -76,7 +84,7 @@ export const RecipePage = (): JSX.Element => {
    { Ingredients
      - - {(line: string) =>
    • {line}
    • } + + {(line) =>
    • {line}
    • }
@@ -107,7 +115,7 @@ export const RecipePage = (): JSX.Element => { Steps
    - {(step: string) =>
  1. {step}
  2. }
    + {(step) =>
  3. {step}
  4. }
From 65eafdfe25effc3df81db5600ac18580435faa42 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 17:32:25 +0100 Subject: [PATCH 8/9] refactor(web): extract RecipeCard and recipe-detail section components - RecipeCard for home list items; RecipeDetailBody composes header, hero, ingredients, and steps - RecipePage renders RecipeDetailBody from loaded RecipeDetail Made-with: Cursor --- apps/web/src/components/RecipeCard.tsx | 36 +++++++++ .../recipe-detail/RecipeDetailBody.tsx | 28 +++++++ .../recipe-detail/RecipeDetailHeader.tsx | 32 ++++++++ .../recipe-detail/RecipeDetailHero.tsx | 20 +++++ .../recipe-detail/RecipeDetailIngredients.tsx | 23 ++++++ .../recipe-detail/RecipeDetailSteps.tsx | 18 +++++ apps/web/src/pages/Home.tsx | 30 +------ apps/web/src/pages/RecipePage.tsx | 79 +------------------ 8 files changed, 162 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/components/RecipeCard.tsx create mode 100644 apps/web/src/components/recipe-detail/RecipeDetailBody.tsx create mode 100644 apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx create mode 100644 apps/web/src/components/recipe-detail/RecipeDetailHero.tsx create mode 100644 apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx create mode 100644 apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx diff --git a/apps/web/src/components/RecipeCard.tsx b/apps/web/src/components/RecipeCard.tsx new file mode 100644 index 0000000..fda68ce --- /dev/null +++ b/apps/web/src/components/RecipeCard.tsx @@ -0,0 +1,36 @@ +import { A } from '@solidjs/router'; +import type { JSX } from 'solid-js'; +import type { RecipeSummary } from '@/api'; + +export const RecipeCard = ({ + id, + imageUrl, + title, + summary, +}: RecipeSummary): JSX.Element => ( +
  • + +
    + +
    +
    +

    + {title} +

    +

    {summary}

    +
    +
    +
  • +); diff --git a/apps/web/src/components/recipe-detail/RecipeDetailBody.tsx b/apps/web/src/components/recipe-detail/RecipeDetailBody.tsx new file mode 100644 index 0000000..0c018bf --- /dev/null +++ b/apps/web/src/components/recipe-detail/RecipeDetailBody.tsx @@ -0,0 +1,28 @@ +import type { JSX } from 'solid-js'; +import type { RecipeDetail } from '@/api'; +import { RecipeDetailHeader } from './RecipeDetailHeader'; +import { RecipeDetailHero } from './RecipeDetailHero'; +import { RecipeDetailIngredients } from './RecipeDetailIngredients'; +import { RecipeDetailSteps } from './RecipeDetailSteps'; + +export const RecipeDetailBody = ({ + title, + summary, + prepTimeMinutes, + bakeTimeMinutes, + imageUrlLarge, + ingredients, + steps, +}: RecipeDetail): JSX.Element => ( + <> + + + + + +); diff --git a/apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx b/apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx new file mode 100644 index 0000000..c9b4448 --- /dev/null +++ b/apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx @@ -0,0 +1,32 @@ +import type { JSX } from 'solid-js'; +import { Show } from 'solid-js'; +import type { RecipeDetail } from '@/api'; + +type RecipeDetailHeaderProps = Pick< + RecipeDetail, + 'title' | 'summary' | 'prepTimeMinutes' | 'bakeTimeMinutes' +>; + +export const RecipeDetailHeader = ({ + title, + summary, + prepTimeMinutes, + bakeTimeMinutes, +}: RecipeDetailHeaderProps): JSX.Element => ( +
    +

    + {title} +

    +

    {summary}

    + +

    + + Prep: {prepTimeMinutes} min + + + Bake: {bakeTimeMinutes} min + +

    +
    +
    +); diff --git a/apps/web/src/components/recipe-detail/RecipeDetailHero.tsx b/apps/web/src/components/recipe-detail/RecipeDetailHero.tsx new file mode 100644 index 0000000..23cc94c --- /dev/null +++ b/apps/web/src/components/recipe-detail/RecipeDetailHero.tsx @@ -0,0 +1,20 @@ +import type { JSX } from 'solid-js'; +import type { RecipeDetail } from '@/api'; + +type RecipeDetailHeroProps = Pick; + +export const RecipeDetailHero = ({ + imageUrlLarge, +}: RecipeDetailHeroProps): JSX.Element => ( +
    + +
    +); diff --git a/apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx b/apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx new file mode 100644 index 0000000..1b9a508 --- /dev/null +++ b/apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx @@ -0,0 +1,23 @@ +import type { JSX } from 'solid-js'; +import { For } from 'solid-js'; +import type { RecipeDetail } from '@/api'; + +type RecipeDetailIngredientsProps = Pick; + +export const RecipeDetailIngredients = ({ + ingredients, +}: RecipeDetailIngredientsProps): JSX.Element => ( +
    +

    + Ingredients +

    +
      + + {(line: string): JSX.Element =>
    • {line}
    • } +
      +
    +
    +); diff --git a/apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx b/apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx new file mode 100644 index 0000000..e5586e6 --- /dev/null +++ b/apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx @@ -0,0 +1,18 @@ +import type { JSX } from 'solid-js'; +import { For } from 'solid-js'; +import type { RecipeDetail } from '@/api'; + +type RecipeDetailStepsProps = Pick; + +export const RecipeDetailSteps = ({ + steps, +}: RecipeDetailStepsProps): JSX.Element => ( +
    +

    + Steps +

    +
      + {(step: string): JSX.Element =>
    1. {step}
    2. }
      +
    +
    +); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index ff64467..79f5f1f 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -1,8 +1,8 @@ -import { A } from '@solidjs/router'; import type { JSX } from 'solid-js'; import { createResource, For, Show } from 'solid-js'; import type { RecipeSummary } from '@/api'; import { listRecipes } from '@/api'; +import { RecipeCard } from '@/components/RecipeCard'; import './Page.css'; export const Home = (): JSX.Element => { @@ -45,33 +45,7 @@ export const Home = (): JSX.Element => { > diff --git a/apps/web/src/pages/RecipePage.tsx b/apps/web/src/pages/RecipePage.tsx index 082a325..4f33c48 100644 --- a/apps/web/src/pages/RecipePage.tsx +++ b/apps/web/src/pages/RecipePage.tsx @@ -1,8 +1,9 @@ import { A, useParams } from '@solidjs/router'; import type { JSX } from 'solid-js'; -import { createResource, For, Show } from 'solid-js'; +import { createResource, Show } from 'solid-js'; import type { RecipeDetail } from '@/api'; import { getRecipeById } from '@/api'; +import { RecipeDetailBody } from '@/components/recipe-detail/RecipeDetailBody'; import './Page.css'; export const RecipePage = (): JSX.Element => { @@ -45,81 +46,7 @@ export const RecipePage = (): JSX.Element => { - {({ - title, - summary, - prepTimeMinutes, - bakeTimeMinutes, - imageUrlLarge, - ingredients, - steps, - }: RecipeDetail) => ( - <> -
    -

    - {title} -

    -

    {summary}

    - -

    - - - Prep: {prepTimeMinutes} min - - - - - Bake: {bakeTimeMinutes} min - - -

    -
    -
    - -
    - -
    - -
    -

    - Ingredients -

    -
      - - {(line) =>
    • {line}
    • } -
      -
    -
    - -
    -

    - Steps -

    -
      - {(step) =>
    1. {step}
    2. }
      -
    -
    - - )} + {(detail: RecipeDetail) => }
    From fe3b460a345b32501cf95b2dcf31e498d6b53b44 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Fri, 3 Apr 2026 17:36:50 +0100 Subject: [PATCH 9/9] refactor(web): use ./ imports from App to layout and pages Made-with: Cursor --- apps/web/src/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 645099c..abb7cc4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,8 @@ import { Route, Router } from '@solidjs/router'; import type { JSX } from 'solid-js'; -import { AppShell } from '@/layout/AppShell'; -import { Home } from '@/pages/Home'; -import { RecipePage } from '@/pages/RecipePage'; +import { AppShell } from './layout/AppShell'; +import { Home } from './pages/Home'; +import { RecipePage } from './pages/RecipePage'; export const App = (): JSX.Element => (