Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3171e75
feat: add archived field to Recipe model and archive/unarchive mutations
m-lyon Mar 30, 2026
329bfb1
feat: replace delete with archive/unarchive across client
m-lyon Mar 30, 2026
b78b505
fix: resolve stale closure bugs in useSearch and remove spec from tra…
m-lyon Mar 30, 2026
4ddeadd
test: add unarchive test coverage and archived recipe mocks
m-lyon Mar 30, 2026
0c3a85b
test: replace recipeRemoveById tests with archive/unarchive tests
m-lyon Mar 30, 2026
1f641dc
fix: correct assertion order in archive test to check null before pro…
m-lyon Mar 30, 2026
f106f4c
feat: keep search bar and filters visible when viewing archived recipes
m-lyon Apr 1, 2026
9973ad8
refactor: format API code and replace custom SVG icons with react-icons
m-lyon Apr 1, 2026
5e25bc7
fix: address review findings from roborev
m-lyon Apr 1, 2026
2ba6069
feat: convert ConfirmArchiveModal to Mantine and add archive error no…
m-lyon Apr 2, 2026
9cd2eac
fix: await archive mutation before closing modal and verify card pers…
m-lyon Apr 2, 2026
b5a2ae5
fixes
m-lyon Apr 6, 2026
a440708
style: add Mantine theme to match Chakra styling for modal and buttons
m-lyon Apr 6, 2026
b6341da
style: use exact Chakra color values for archive modal buttons
m-lyon Apr 6, 2026
4f1f52e
undo
m-lyon Apr 6, 2026
c178cfb
style: match cancel hover to border color and reduce button size
m-lyon Apr 6, 2026
78bc6f4
fix: use outline variant for cancel button and restore resetToHome
m-lyon Apr 6, 2026
b67e884
refactor: extract button styles into reusable Mantine 'cancel' and 'd…
m-lyon Apr 6, 2026
3be4a8b
theme fix
m-lyon Apr 7, 2026
01a3f68
fix: search reset now preserves archived view state
m-lyon Apr 7, 2026
f2c70e3
added AGENTS.md
m-lyon Apr 7, 2026
f514421
fix: close archive modal immediately on confirm
m-lyon Apr 7, 2026
20cf208
fin
m-lyon Apr 7, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,5 @@ api/seed_data/
.vitest-preview/

# LLM docs
*llm.txt
*llm.txt
spec-document.md
194 changes: 194 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# AGENTS.md

Guidance for AI agents working on this codebase.

## Project Structure

Two independent Node.js projects in one git repo. No root `package.json` or monorepo tooling.

```
api/ # Express + Apollo Server + Mongoose (Node backend)
client/ # React + Vite + Apollo Client (frontend SPA)
.github/ # CI/CD workflows
```

## Tech Stack

| Layer | Technology |
| ----- | ---------- |
| API server | Express 4 + Apollo Server 4 |
| API GraphQL | graphql-compose + graphql-compose-mongoose (auto-CRUD from Mongoose models) |
| Database | MongoDB via Mongoose 7 |
| Auth | Passport + passport-local-mongoose (session-based) |
| Client framework | React 18 + TypeScript |
| Client build | Vite 6 |
| Client GraphQL | Apollo Client 3 |
| Client UI | Chakra UI 2 **and** Mantine 8 (both used simultaneously) |
| Client state | Zustand 5 (slice pattern) |
| Client routing | react-router-dom 6 |

## Commands

### API (`cd api/`)

| Task | Command |
| ---- | ------- |
| Install deps | `npm install` |
| Compile TS | `npm run compile` |
| Run tests | `npm test` (compiles first, then runs mocha) |
| Type check | `npm run check-types` |
| Dev server | `npm run dev` |
| Start server | `NODE_ENV=development node ./dist/src/index.js` |

There is **no lint script** in the API. ESLint config exists but there is no `npm run lint`.

### Client (`cd client/`)

| Task | Command |
| ---- | ------- |
| Install deps | `npm install` |
| Lint | `npm run lint` |
| Fix formatting | `npm run lint -- --fix` |
| Run tests | `npm test` (vitest, watch mode) |
| Run tests once | `npm test -- run` |
| Type check | `npm run check-types` |
| Codegen | `npm run generate` (requires running API, see below) |
| Build | `npm run build` |
| Dev server | `npm run dev` |

## Running Tests

### API tests

```bash
cd api && npm test
```

- Uses **Mocha** + **Chai** with **mongodb-memory-server** (in-memory MongoDB).
- TypeScript compiles to `dist/` first, then mocha runs the JS.
- Tests at `api/test/graphql/*.test.ts` (resolver-level) and `api/test/mongoose/*.test.ts` (model-level).
- Pattern: `before(startServer)` / `after(stopServer)` / `beforeEach(createData)` / `afterEach(removeData)`.
- Tests call `context.apolloServer.executeOperation()` with `contextValue` for auth mocking.

### Client tests

```bash
cd client && npm test -- run
```

- Uses **Vitest** + **@testing-library/react** + **happy-dom**.
- Test timeout: 15000ms.
- Integration tests at `client/src/__tests__/index.*.test.tsx`.
- Page tests at `client/src/pages/__tests__/*.test.tsx`.
- Zustand stores auto-reset between tests via the mock at `client/__mocks__/zustand.ts`.
- Apollo mocking uses `MockedProvider` with per-test mock arrays.
- Browser tests (`*.browser.test.tsx`) use Playwright and are currently flaky/disabled in CI.

### Pre-existing test failures

These fail on `main` and are not caused by your changes:

- `EditableIngredient.browser.test.tsx` -- browser mode config issue.

## GraphQL Codegen

The client uses `@graphql-codegen/cli` to generate TypeScript types from the API schema via introspection. **This requires the API to be running.**

```bash
# 1. Start the API (must be on port 4004 for test/dev)
cd api && npm install && npm run compile
NODE_ENV=development node ./dist/src/index.js &

# 2. Run codegen
cd client && npm run generate
```

- Output goes to `client/src/__generated__/` (gitignored).
- After any GraphQL schema change (queries, mutations, fragments), you must re-run codegen.
- In CI, `npm run generate:test` is used with the API started in test mode.

## Code Conventions

### Formatting (Prettier via ESLint)

- Single quotes, JSX single quotes
- 4-space indentation
- 100 char print width
- Trailing commas (es5)

### Import ordering (enforced by ESLint)

1. Builtin modules
2. External packages
3. Internal (`@recipe/**`)
4. Sibling/parent
5. Index

Blank line between each group. `@recipe/**` imports are grouped after externals.

### Client path aliases

All aliases resolve from `client/src/`:

| Alias | Path |
| ----- | ---- |
| `@recipe/graphql/*` | `graphql/*` |
| `@recipe/graphql/generated` | `__generated__/graphql` |
| `@recipe/features/*` | `features/*` |
| `@recipe/utils/*` | `utils/*` |
| `@recipe/layouts` | `layouts` |
| `@recipe/stores` | `stores` |
| `@recipe/constants` | `constants.ts` |
| `@recipe/common/components` | `common/components` |
| `@recipe/common/hooks` | `common/hooks` |
| `@recipe/common/icons` | `common/icons` |

Vite resolves these automatically via `vite-tsconfig-paths`.

### Feature module pattern (client)

Features live in `client/src/features/<name>/` with subdirectories for `components/`, `hooks/`, `utils/`, and a barrel `index.tsx`.

**Deep imports into features are ESLint-blocked.** You must import through the barrel:

```typescript
// Good
import { SearchBar } from '@recipe/features/search';

// Bad -- ESLint error
import { SearchBar } from '@recipe/features/search/components/SearchBar';
```

### GraphQL mock data (client)

Each `graphql/mutations/<entity>.ts` and `graphql/queries/<entity>.ts` has a parallel `__mocks__/<entity>.ts` with test fixtures. Default mocks for integration tests are composed in `src/__mocks__/graphql.ts`.

When adding a new field to a query/mutation, update the corresponding `__mocks__` file with the field in all mock objects.

### API resolver pattern (graphql-compose)

1. Define Mongoose model + schema in `api/src/models/`.
2. `composeMongoose()` generates a TypeComposer (TC) with auto-CRUD resolvers.
3. Custom resolvers added via `TC.addResolver()` in `api/src/schema/<Entity>.ts`.
4. Authorization applied via `composeResolvers()` in `api/src/schema/index.ts`.
5. Authorization layers: `isAdmin`, `isVerified`, `isDocumentOwnerOrAdmin(Model)`.

### Global types (client)

`client/src/types/recipe.d.ts` declares global TypeScript types derived from generated GraphQL types. These are available without imports (e.g., `RecipePreview`, `RecipeView`, `EditableRecipeIngredient`).

## CI/CD

GitHub Actions workflow (`.github/workflows/deploy.yml`) on push to `main`:

1. **test job**: Install both projects, run API tests (mocha), start API, run codegen, run client tests (vitest).
2. **deploy job**: Compile API (prod), build client, deploy both via SSH/rsync.

## Common Pitfalls

- **Forgetting codegen**: After changing GraphQL operations (queries, mutations, fragments), run `npm run generate` in `client/`. The `__generated__/` directory is gitignored and must be regenerated.
- **Codegen needs a running API**: The codegen config introspects the schema from a live server. Start the API first.
- **API tests require compilation**: `npm test` compiles TS to `dist/` then runs mocha on JS files. If you edit `.ts` files, the tests always recompile.
- **Dual `useSearch` hook instances**: Both `Navbar` and `RecipeCardsContainer` call `useSearch()`, creating separate `useLazyQuery` instances. Apollo mocks for search queries may need to be provided twice.
- **Apollo cache `keyArgs: []`**: `recipeMany` and `recipeCount` have `keyArgs: []` in the cache config (`client/src/utils/cache.ts`), meaning all queries of each type share a single cache entry regardless of filter/pagination args.
- **Mock recipe display names**: `mockRecipeTwo` has `isIngredient: true`, so `getCardTitle()` returns `pluralTitle` not `title`. Use `aria-label` to find card elements in tests.
139 changes: 70 additions & 69 deletions api/package.json
Original file line number Diff line number Diff line change
@@ -1,71 +1,72 @@
{
"name": "api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"test": "test"
},
"scripts": {
"compile": "rm -rf ./dist && tsc",
"start": "node ./dist/src/index.js",
"dev": "NODE_ENV=development nodemon -e ts --exec \"npm run compile && npm run start\"",
"prod": "NODE_ENV=production node ./dist/index.js",
"prod:watch": "nodemon -e ts --exec \"npm run prod\"",
"prod:compile": "tsc -p tsconfig.prod.json",
"test": "npm run compile && npm run start:test --",
"start:test": "NODE_ENV=test mocha ./dist/test/**/*.test.js",
"seed": "npm run compile && NODE_ENV=development node ./dist/src/scripts/seedDatabase.js",
"dl_memory": "npm run compile && NODE_ENV=test node ./dist/test/scripts/downloadMemoryServer.js",
"check-types": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@apollo/server": "^4.7.4",
"@graphql-tools/resolvers-composition": "^7.0.0",
"@sendgrid/mail": "^8.1.4",
"@types/cors": "^2.8.17",
"@types/express-session": "^1.17.10",
"@types/graphql-upload": "^16.0.7",
"body-parser": "^1.20.2",
"connect-mongo": "^5.1.0",
"cors": "^2.8.5",
"dotenv-flow": "^3.3.0",
"express": "^4.18.2",
"express-session": "^1.17.3",
"graphql": "^16.6.0",
"graphql-compose": "^9.0.10",
"graphql-compose-mongoose": "^9.8.0",
"graphql-passport": "^0.6.7",
"graphql-upload": "^16.0.2",
"mongoose": "^7.3.0",
"nanoid": "^5.0.5",
"passport": "^0.6.0",
"passport-local-mongoose": "^8.0.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/chai": "^4.3.12",
"@types/dotenv-flow": "^3.3.3",
"@types/mocha": "^10.0.6",
"@types/mongoose": "^5.11.97",
"@types/node": "^20.19.11",
"@types/sinon": "^17.0.4",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"chai": "^5.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-import-line-sorter": "^1.0.4",
"eslint-plugin-prettier": "^5.1.3",
"mocha": "^10.3.0",
"mongodb-memory-server-core": "^9.4.1",
"sinon": "^21.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.1.6"
}
"name": "api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"test": "test"
},
"scripts": {
"compile": "rm -rf ./dist && tsc",
"start": "node ./dist/src/index.js",
"dev": "NODE_ENV=development nodemon -e ts --exec \"npm run compile && npm run start\"",
"prod": "NODE_ENV=production node ./dist/index.js",
"prod:watch": "nodemon -e ts --exec \"npm run prod\"",
"prod:compile": "tsc -p tsconfig.prod.json",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "npm run compile && npm run start:test --",
"start:test": "NODE_ENV=test mocha ./dist/test/**/*.test.js",
"seed": "npm run compile && NODE_ENV=development node ./dist/src/scripts/seedDatabase.js",
"dl_memory": "npm run compile && NODE_ENV=test node ./dist/test/scripts/downloadMemoryServer.js",
"check-types": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@apollo/server": "^4.7.4",
"@graphql-tools/resolvers-composition": "^7.0.0",
"@sendgrid/mail": "^8.1.4",
"@types/cors": "^2.8.17",
"@types/express-session": "^1.17.10",
"@types/graphql-upload": "^16.0.7",
"body-parser": "^1.20.2",
"connect-mongo": "^5.1.0",
"cors": "^2.8.5",
"dotenv-flow": "^3.3.0",
"express": "^4.18.2",
"express-session": "^1.17.3",
"graphql": "^16.6.0",
"graphql-compose": "^9.0.10",
"graphql-compose-mongoose": "^9.8.0",
"graphql-passport": "^0.6.7",
"graphql-upload": "^16.0.2",
"mongoose": "^7.3.0",
"nanoid": "^5.0.5",
"passport": "^0.6.0",
"passport-local-mongoose": "^8.0.0",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/chai": "^4.3.12",
"@types/dotenv-flow": "^3.3.3",
"@types/mocha": "^10.0.6",
"@types/mongoose": "^5.11.97",
"@types/node": "^20.19.11",
"@types/sinon": "^17.0.4",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"chai": "^5.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-import-line-sorter": "^1.0.4",
"eslint-plugin-prettier": "^5.1.3",
"mocha": "^10.3.0",
"mongodb-memory-server-core": "^9.4.1",
"sinon": "^21.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.1.6"
}
}
13 changes: 11 additions & 2 deletions api/src/models/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export interface Recipe extends Document {
source?: string;
numServings: number;
isIngredient: boolean;
archived: boolean;
createdAt: Date;
lastModified: Date;
}
Expand Down Expand Up @@ -250,6 +251,7 @@ const recipeSchema = new Schema<Recipe>({
source: { type: String },
numServings: { type: Number, required: true },
isIngredient: { type: Boolean, required: true },
archived: { type: Boolean, default: false },
createdAt: { type: Date, required: true },
lastModified: { type: Date, required: true },
});
Expand Down Expand Up @@ -291,10 +293,17 @@ export const RecipeIngredientTC = composeMongoose(RecipeIngredient, { removeFiel
export const Recipe = model<Recipe>('Recipe', recipeSchema);
export const RecipeTC = composeMongoose(Recipe);
export const RecipeModifyTC = composeMongoose(Recipe, {
removeFields: ['titleIdentifier', 'calculatedTags', 'createdAt', 'lastModified'],
removeFields: ['titleIdentifier', 'calculatedTags', 'archived', 'createdAt', 'lastModified'],
name: 'RecipeModify',
});
export const RecipeCreateTC = composeMongoose(Recipe, {
removeFields: ['owner', 'titleIdentifier', 'calculatedTags', 'createdAt', 'lastModified'],
removeFields: [
'owner',
'titleIdentifier',
'calculatedTags',
'archived',
'createdAt',
'lastModified',
],
name: 'RecipeCreate',
});
Loading