Compare commits
18 Commits
v1.3.1
...
35989f8120
| Author | SHA1 | Date | |
|---|---|---|---|
| 35989f8120 | |||
| b974675b11 | |||
| c4ce96ce4f | |||
| 60db8bd9de | |||
| ecbfbc00e9 | |||
| f7ce380104 | |||
| 0d7c4f476a | |||
| 86a4a747b5 | |||
| e9d33e59e9 | |||
| 5308991123 | |||
| a6e7035aab | |||
| 0eaf401cce | |||
| a3061b22ca | |||
| 1dff6abb3b | |||
| 2dddba9a08 | |||
| 41a2910aeb | |||
| ecff58500e | |||
| 3016eb1a1a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -226,6 +226,11 @@ uploads/*
|
|||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/test.db
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ Replaces spreadsheet-based gear tracking workflow.
|
|||||||
- **Runtime**: Bun — used as package manager and runtime
|
- **Runtime**: Bun — used as package manager and runtime
|
||||||
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
- **Design**: Light, airy, minimalist — white/light backgrounds, lots of whitespace, no visual clutter
|
||||||
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
- **Navigation**: Dashboard-based home page, not sidebar or top-nav tabs
|
||||||
- **Scope**: No auth, single user for v1
|
- **Scope**: Single user with cookie/API key auth
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| No auth for v1 | Single user, simplicity first | ✓ Good |
|
| Cookie/API key auth | Single user, public read + authenticated write | ✓ Good |
|
||||||
| Generic data model | Support any hobby, not just bikepacking | ✓ Good |
|
| Generic data model | Support any hobby, not just bikepacking | ✓ Good |
|
||||||
| Dashboard navigation | Clean entry point, not persistent nav | ✓ Good |
|
| Dashboard navigation | Clean entry point, not persistent nav | ✓ Good |
|
||||||
| Bun runtime | User preference | ✓ Good |
|
| Bun runtime | User preference | ✓ Good |
|
||||||
|
|||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -19,8 +19,10 @@ bun run db:generate # Generate Drizzle migration from schema changes
|
|||||||
bun run db:push # Apply migrations to gearbox.db
|
bun run db:push # Apply migrations to gearbox.db
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
bun test # Run all tests
|
bun test # Run all unit/integration tests
|
||||||
bun test tests/services/item.service.test.ts # Run single test file
|
bun test tests/services/item.service.test.ts # Run single test file
|
||||||
|
bun run test:e2e # Run Playwright E2E tests
|
||||||
|
bun run test:e2e:ui # Playwright UI mode for debugging
|
||||||
|
|
||||||
# Lint & Format
|
# Lint & Format
|
||||||
bun run lint # Biome check (tabs, double quotes, organized imports)
|
bun run lint # Biome check (tabs, double quotes, organized imports)
|
||||||
@@ -55,9 +57,16 @@ bun run build # Vite build → dist/client/
|
|||||||
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
|
- **Timestamps**: stored as integers (unix epoch) with `{ mode: "timestamp" }`.
|
||||||
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`, `users`, `sessions`, `apiKeys`.
|
- Tables: `categories`, `items`, `threads`, `threadCandidates`, `setups`, `setupItems`, `settings`, `users`, `sessions`, `apiKeys`.
|
||||||
|
|
||||||
### Testing (`tests/`)
|
### Testing (`tests/` and `e2e/`)
|
||||||
- Bun test runner. Tests at service level and route level.
|
- **Unit/integration**: Bun test runner (`bun test`). Tests at service level and route level.
|
||||||
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite with full schema and seeds an "Uncategorized" category. When adding schema columns, update both `src/db/schema.ts` and the test helper's CREATE TABLE statements.
|
- `tests/helpers/db.ts`: `createTestDb()` creates in-memory SQLite via Drizzle migrations and seeds an "Uncategorized" category.
|
||||||
|
- **E2E**: Playwright (`bun run test:e2e`). Tests in `e2e/` run against a seeded SQLite database with the server in production mode. Seed script: `e2e/seed.ts`.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
- **Develop** is the main branch. Keep it clean — don't commit large feature work directly.
|
||||||
|
- For each new brainstorming/implementation session, create a feature branch off Develop (e.g., `feature/setup-impact-preview`, `fix/error-handling`).
|
||||||
|
- Merge back to Develop via PR or fast-forward merge when the work is complete and verified.
|
||||||
|
|
||||||
## Path Alias
|
## Path Alias
|
||||||
|
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.7",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-router-devtools": "^1.166.7",
|
"@tanstack/react-router-devtools": "^1.166.7",
|
||||||
"@tanstack/router-plugin": "^1.166.9",
|
"@tanstack/router-plugin": "^1.166.9",
|
||||||
@@ -185,6 +186,8 @@
|
|||||||
|
|
||||||
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
||||||
|
|
||||||
|
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||||
|
|
||||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
|
||||||
@@ -675,6 +678,10 @@
|
|||||||
|
|
||||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||||
|
|
||||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||||
@@ -879,6 +886,8 @@
|
|||||||
|
|
||||||
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|||||||
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
661
docs/superpowers/plans/2026-04-03-codebase-improvements.md
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
# Codebase Improvements Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Harden the server (explicit DB context, param validation, error handling, rate limiting), add client error boundaries, split the oversized collection route into focused components, and fix stale docs.
|
||||||
|
|
||||||
|
**Architecture:** Server changes are middleware-level (DB context, error handler, rate limiter) plus a small utility for param parsing. Client changes are a TanStack Router error boundary on the root route and extracting three tab components from the 634-line collection route. Docs change is a one-line fix in PROJECT.md.
|
||||||
|
|
||||||
|
**Tech Stack:** Hono middleware, TanStack Router errorComponent, React, TypeScript
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Explicit DB Context Middleware
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/index.ts:1-59`
|
||||||
|
- Modify: `src/server/routes/settings.ts:3,12` (remove prodDb fallback)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DB import and middleware to server index**
|
||||||
|
|
||||||
|
In `src/server/index.ts`, add the import for the production database at the top, alongside existing imports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db as prodDb } from "../db/index.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add a middleware **before** the auth middleware (before line 26) that sets the DB on every API request:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Inject production database into request context
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
c.set("db", prodDb);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix auth middleware comment**
|
||||||
|
|
||||||
|
In the same file, update the comment on the auth middleware from:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove prodDb fallback from settings route**
|
||||||
|
|
||||||
|
In `src/server/routes/settings.ts`, remove the `prodDb` import and fallback. Change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db as prodDb } from "../../db/index.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove this import entirely.
|
||||||
|
|
||||||
|
Change both occurrences of:
|
||||||
|
```ts
|
||||||
|
const database = c.get("db") ?? prodDb;
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```ts
|
||||||
|
const database = c.get("db");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass. Tests already set `c.set("db", testDb)` so this change doesn't affect them.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/index.ts src/server/routes/settings.ts
|
||||||
|
git commit -m "fix: add explicit DB context middleware for all API routes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Route Parameter Validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/server/lib/params.ts`
|
||||||
|
- Modify: `src/server/routes/items.ts`
|
||||||
|
- Modify: `src/server/routes/categories.ts`
|
||||||
|
- Modify: `src/server/routes/threads.ts`
|
||||||
|
- Modify: `src/server/routes/setups.ts`
|
||||||
|
- Modify: `src/server/routes/auth.ts:187-189`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create parseId helper**
|
||||||
|
|
||||||
|
Create `src/server/lib/params.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Parse a route parameter as a positive integer ID.
|
||||||
|
* Returns the number if valid, or null if the string is not a positive integer.
|
||||||
|
*/
|
||||||
|
export function parseId(raw: string): number | null {
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update items routes**
|
||||||
|
|
||||||
|
In `src/server/routes/items.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all `Number(c.req.param("id"))` patterns. For each route that uses an ID param, add validation. Example for `GET /:id`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.get("/:id", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||||
|
const item = getItemById(db, id);
|
||||||
|
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||||
|
return c.json(item);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same pattern to `PUT /:id` and `DELETE /:id`. In each case, add `const id = parseId(...)` + the null check returning 400 right after.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update categories routes**
|
||||||
|
|
||||||
|
In `src/server/routes/categories.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `Number(c.req.param("id"))` with `parseId(c.req.param("id"))` in `PUT /:id` and `DELETE /:id`, adding the null check:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update threads routes**
|
||||||
|
|
||||||
|
In `src/server/routes/threads.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all `Number(c.req.param(...))` calls. There are 8 occurrences across these handlers:
|
||||||
|
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||||
|
- `PUT /:id` — same
|
||||||
|
- `DELETE /:id` — same
|
||||||
|
- `POST /:id/candidates` — `const threadId = parseId(c.req.param("id"))`
|
||||||
|
- `PUT /:threadId/candidates/:candidateId` — `const candidateId = parseId(c.req.param("candidateId"))`
|
||||||
|
- `DELETE /:threadId/candidates/:candidateId` — same
|
||||||
|
- `PATCH /:id/candidates/reorder` — `const threadId = parseId(c.req.param("id"))`
|
||||||
|
- `POST /:id/resolve` — `const threadId = parseId(c.req.param("id"))`
|
||||||
|
|
||||||
|
For each, add the null check returning 400 with a descriptive message like `"Invalid thread ID"` or `"Invalid candidate ID"`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update setups routes**
|
||||||
|
|
||||||
|
In `src/server/routes/setups.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all `Number(c.req.param(...))` calls. There are 6 occurrences:
|
||||||
|
- `GET /:id` — `const id = parseId(c.req.param("id"))`
|
||||||
|
- `PUT /:id` — same
|
||||||
|
- `DELETE /:id` — same
|
||||||
|
- `PUT /:id/items` — same
|
||||||
|
- `PATCH /:id/items/:itemId/classification` — both `setupId` and `itemId`
|
||||||
|
- `DELETE /:id/items/:itemId` — both `setupId` and `itemId`
|
||||||
|
|
||||||
|
For the classification and item removal routes with two params:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const setupId = parseId(c.req.param("id"));
|
||||||
|
const itemId = parseId(c.req.param("itemId"));
|
||||||
|
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update auth routes**
|
||||||
|
|
||||||
|
In `src/server/routes/auth.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `DELETE /keys/:id` (line 187-189):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.delete("/keys/:id", requireAuth, (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid key ID" }, 400);
|
||||||
|
deleteApiKey(db, id);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass. Existing tests use valid integer IDs so no breakage.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/lib/params.ts src/server/routes/items.ts src/server/routes/categories.ts src/server/routes/threads.ts src/server/routes/setups.ts src/server/routes/auth.ts
|
||||||
|
git commit -m "fix: validate route ID parameters, return 400 for invalid IDs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Centralized Error Handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add onError handler**
|
||||||
|
|
||||||
|
In `src/server/index.ts`, add the error handler after the app is created (after `const app = new Hono()`) but before any routes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Centralized error handler
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||||
|
const message =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "Internal server error"
|
||||||
|
: err.message || "Internal server error";
|
||||||
|
return c.json({ error: message }, 500);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/index.ts
|
||||||
|
git commit -m "fix: add centralized error handler for unhandled exceptions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Rate Limiting on Auth Endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/server/middleware/rateLimit.ts`
|
||||||
|
- Modify: `src/server/routes/auth.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create rate limiter middleware**
|
||||||
|
|
||||||
|
Create `src/server/middleware/rateLimit.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Context, Next } from "hono";
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
function getClientIp(c: Context): string {
|
||||||
|
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (now >= entry.resetAt) {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rateLimit(c: Context, next: Next) {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `${ip}:${c.req.path}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const entry = store.get(key);
|
||||||
|
|
||||||
|
if (!entry || now >= entry.resetAt) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= MAX_ATTEMPTS) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
c.header("Retry-After", String(retryAfter));
|
||||||
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply rate limiter to auth routes**
|
||||||
|
|
||||||
|
In `src/server/routes/auth.ts`, add the import:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { rateLimit } from "../middleware/rateLimit.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `POST /setup` handler to include the rate limiter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `POST /login` handler to include the rate limiter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass. Auth tests make fewer than 5 requests per endpoint so rate limiting won't trigger.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/middleware/rateLimit.ts src/server/routes/auth.ts
|
||||||
|
git commit -m "feat: add rate limiting on login and setup endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Client Error Boundary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/client/routes/__root.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add error boundary component and wire it up**
|
||||||
|
|
||||||
|
In `src/client/routes/__root.tsx`, add the import for `useRouter` at the top (add to existing import from `@tanstack/react-router`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
createRootRoute,
|
||||||
|
Outlet,
|
||||||
|
useMatchRoute,
|
||||||
|
useNavigate,
|
||||||
|
useRouter,
|
||||||
|
type ErrorComponentProps,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `errorComponent` to the route definition:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
|
errorComponent: RootErrorBoundary,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `RootErrorBoundary` function before `RootLayout`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="max-w-md mx-auto text-center px-4">
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
router.invalidate();
|
||||||
|
}}
|
||||||
|
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/client/routes/__root.tsx
|
||||||
|
git commit -m "feat: add error boundary to root route for crash resilience"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Split Collection Route into Tab Components
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/client/components/CollectionView.tsx`
|
||||||
|
- Create: `src/client/components/PlanningView.tsx`
|
||||||
|
- Create: `src/client/components/SetupsView.tsx`
|
||||||
|
- Modify: `src/client/routes/collection/index.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create CollectionView component**
|
||||||
|
|
||||||
|
Create `src/client/components/CollectionView.tsx` with the `CollectionView` function extracted from `collection/index.tsx` (lines 72-334). The component needs these imports:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CategoryHeader } from "./CategoryHeader";
|
||||||
|
import { ItemCard } from "./ItemCard";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
|
import { useItems } from "../hooks/useItems";
|
||||||
|
import { useTotals } from "../hooks/useTotals";
|
||||||
|
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||||
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export function CollectionView() {
|
||||||
|
// ... exact same function body as lines 73-334 of collection/index.tsx
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the entire `CollectionView` function body as-is. No logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create PlanningView component**
|
||||||
|
|
||||||
|
Create `src/client/components/PlanningView.tsx` with the `PlanningView` function extracted from `collection/index.tsx` (lines 337-523):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CreateThreadModal } from "./CreateThreadModal";
|
||||||
|
import { ThreadCard } from "./ThreadCard";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useThreads } from "../hooks/useThreads";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
|
||||||
|
export function PlanningView() {
|
||||||
|
// ... exact same function body as lines 338-523 of collection/index.tsx
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the entire `PlanningView` function body as-is. No logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create SetupsView component**
|
||||||
|
|
||||||
|
Create `src/client/components/SetupsView.tsx` with the `SetupsView` function extracted from `collection/index.tsx` (lines 526-633):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SetupCard } from "./SetupCard";
|
||||||
|
import { useCreateSetup, useSetups } from "../hooks/useSetups";
|
||||||
|
|
||||||
|
export function SetupsView() {
|
||||||
|
// ... exact same function body as lines 527-633 of collection/index.tsx
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the entire `SetupsView` function body as-is. No logic changes.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update collection/index.tsx**
|
||||||
|
|
||||||
|
Replace the entire file content. Keep only the route definition, tab switching logic, animation constants, and imports from the new components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { CollectionView } from "../../components/CollectionView";
|
||||||
|
import { PlanningView } from "../../components/PlanningView";
|
||||||
|
import { SetupsView } from "../../components/SetupsView";
|
||||||
|
|
||||||
|
const searchSchema = z.object({
|
||||||
|
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/collection/")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
|
component: CollectionPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TAB_ORDER = ["gear", "planning", "setups"] as const;
|
||||||
|
|
||||||
|
const slideVariants = {
|
||||||
|
enter: (dir: number) => ({ x: `${dir * 15}%`, opacity: 0 }),
|
||||||
|
center: { x: 0, opacity: 1 },
|
||||||
|
exit: (dir: number) => ({ x: `${dir * -15}%`, opacity: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
function CollectionPage() {
|
||||||
|
const { tab } = Route.useSearch();
|
||||||
|
const prevTab = useRef(tab);
|
||||||
|
|
||||||
|
const direction =
|
||||||
|
TAB_ORDER.indexOf(tab) >= TAB_ORDER.indexOf(prevTab.current) ? 1 : -1;
|
||||||
|
prevTab.current = tab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 overflow-x-hidden">
|
||||||
|
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
||||||
|
<motion.div
|
||||||
|
key={tab}
|
||||||
|
custom={direction}
|
||||||
|
variants={slideVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.12, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{tab === "gear" ? (
|
||||||
|
<CollectionView />
|
||||||
|
) : tab === "planning" ? (
|
||||||
|
<PlanningView />
|
||||||
|
) : (
|
||||||
|
<SetupsView />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors. (Biome may flag import organization — fix if needed.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/client/components/CollectionView.tsx src/client/components/PlanningView.tsx src/client/components/SetupsView.tsx src/client/routes/collection/index.tsx
|
||||||
|
git commit -m "refactor: extract tab views from collection route into separate components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Docs Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.planning/PROJECT.md:84`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update stale constraint**
|
||||||
|
|
||||||
|
In `.planning/PROJECT.md`, change line 84 from:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **Scope**: No auth, single user for v1
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **Scope**: Single user with cookie/API key auth
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .planning/PROJECT.md
|
||||||
|
git commit -m "docs: update PROJECT.md constraints to reflect auth implementation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Final Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All 183 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify dev server starts**
|
||||||
|
|
||||||
|
Run: `bun run dev:server &` then `curl http://localhost:3000/api/health`
|
||||||
|
Expected: `{"status":"ok"}`
|
||||||
|
Then kill the background server.
|
||||||
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
934
docs/superpowers/plans/2026-04-03-testing-improvements.md
Normal file
@@ -0,0 +1,934 @@
|
|||||||
|
# Testing Improvements Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add unit tests for new server code (parseId, rate limiter, param validation routes), set up Playwright E2E testing with a seeded database, and write E2E tests covering dashboard, collection, threads, auth, and error handling.
|
||||||
|
|
||||||
|
**Architecture:** Unit tests use existing Bun test runner + Hono `app.request()` pattern. E2E tests use Playwright against a real server with a pre-seeded SQLite database. A global-setup script creates the test DB using Drizzle migrations + direct inserts before Playwright runs.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun test runner, Playwright (Chromium only), Drizzle ORM migrations, Hono
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Unit Tests for parseId
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/lib/params.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
|
Create `tests/lib/params.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { parseId } from "../../src/server/lib/params";
|
||||||
|
|
||||||
|
describe("parseId", () => {
|
||||||
|
it("returns number for valid positive integers", () => {
|
||||||
|
expect(parseId("1")).toBe(1);
|
||||||
|
expect(parseId("42")).toBe(42);
|
||||||
|
expect(parseId("999")).toBe(999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for zero", () => {
|
||||||
|
expect(parseId("0")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for negative numbers", () => {
|
||||||
|
expect(parseId("-1")).toBeNull();
|
||||||
|
expect(parseId("-100")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for decimals", () => {
|
||||||
|
expect(parseId("1.5")).toBeNull();
|
||||||
|
expect(parseId("3.14")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-numeric strings", () => {
|
||||||
|
expect(parseId("abc")).toBeNull();
|
||||||
|
expect(parseId("")).toBeNull();
|
||||||
|
expect(parseId("hello")).toBeNull();
|
||||||
|
expect(parseId("12abc")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for special values", () => {
|
||||||
|
expect(parseId("NaN")).toBeNull();
|
||||||
|
expect(parseId("Infinity")).toBeNull();
|
||||||
|
expect(parseId("-Infinity")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test tests/lib/params.test.ts`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/lib/params.test.ts
|
||||||
|
git commit -m "test: add unit tests for parseId helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Unit Tests for Rate Limiter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/middleware/rateLimit.ts` (add test reset function)
|
||||||
|
- Create: `tests/middleware/rateLimit.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add test reset function to rate limiter**
|
||||||
|
|
||||||
|
In `src/server/middleware/rateLimit.ts`, add at the end of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** @internal — only for testing */
|
||||||
|
export function _resetForTesting() {
|
||||||
|
store.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write tests**
|
||||||
|
|
||||||
|
Create `tests/middleware/rateLimit.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { _resetForTesting, rateLimit } from "../../src/server/middleware/rateLimit";
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
app.post("/login", rateLimit, (c) => c.json({ ok: true }));
|
||||||
|
app.post("/setup", rateLimit, (c) => c.json({ ok: true }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
|
||||||
|
return app.request(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-forwarded-for": ip },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rateLimit middleware", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows first request through", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows up to 5 requests", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 after 5 requests", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("Too many attempts. Try again later.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Retry-After header on 429", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
const retryAfter = res.headers.get("Retry-After");
|
||||||
|
expect(retryAfter).toBeTruthy();
|
||||||
|
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks different IPs independently", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
// Fill up IP 1
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login", "10.0.0.1");
|
||||||
|
}
|
||||||
|
// IP 1 is blocked
|
||||||
|
const blocked = await makeRequest(app, "/login", "10.0.0.1");
|
||||||
|
expect(blocked.status).toBe(429);
|
||||||
|
|
||||||
|
// IP 2 still works
|
||||||
|
const allowed = await makeRequest(app, "/login", "10.0.0.2");
|
||||||
|
expect(allowed.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks different paths independently", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
// Fill up /login
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const blockedLogin = await makeRequest(app, "/login");
|
||||||
|
expect(blockedLogin.status).toBe(429);
|
||||||
|
|
||||||
|
// /setup still works
|
||||||
|
const allowedSetup = await makeRequest(app, "/setup");
|
||||||
|
expect(allowedSetup.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test tests/middleware/rateLimit.test.ts`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run full test suite**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass (previous 183 + new ones).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/middleware/rateLimit.ts tests/middleware/rateLimit.test.ts
|
||||||
|
git commit -m "test: add unit tests for rate limiter middleware"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Route-Level Tests for Invalid ID Params
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/routes/params.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
|
Create `tests/routes/params.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { categoryRoutes } from "../../src/server/routes/categories";
|
||||||
|
import { itemRoutes } from "../../src/server/routes/items";
|
||||||
|
import { setupRoutes } from "../../src/server/routes/setups";
|
||||||
|
import { threadRoutes } from "../../src/server/routes/threads";
|
||||||
|
import { createTestDb } from "../helpers/db";
|
||||||
|
|
||||||
|
function createTestApp() {
|
||||||
|
const db = createTestDb();
|
||||||
|
const app = new Hono();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("db", db);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.route("/api/items", itemRoutes);
|
||||||
|
app.route("/api/categories", categoryRoutes);
|
||||||
|
app.route("/api/threads", threadRoutes);
|
||||||
|
app.route("/api/setups", setupRoutes);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Invalid ID parameter handling", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("items", () => {
|
||||||
|
it("GET /api/items/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toContain("Invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/items/0 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/0");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/items/-1 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/-1");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categories", () => {
|
||||||
|
it("DELETE /api/categories/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/categories/abc", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("threads", () => {
|
||||||
|
it("GET /api/threads/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/threads/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/threads/1.5 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/threads/1.5");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setups", () => {
|
||||||
|
it("GET /api/setups/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/setups/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/setups/0 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/setups/0");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `bun test tests/routes/params.test.ts`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/routes/params.test.ts
|
||||||
|
git commit -m "test: add route-level tests for invalid ID parameter handling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Install Playwright and Create Config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (add dep + scripts)
|
||||||
|
- Create: `playwright.config.ts`
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install Playwright**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add -d @playwright/test
|
||||||
|
bunx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create playwright.config.ts**
|
||||||
|
|
||||||
|
Create `playwright.config.ts` at project root:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: "list",
|
||||||
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add scripts to package.json**
|
||||||
|
|
||||||
|
Add these to the `"scripts"` section in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"test:e2e": "bunx playwright test",
|
||||||
|
"test:e2e:ui": "bunx playwright test --ui"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update .gitignore**
|
||||||
|
|
||||||
|
Append to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Playwright
|
||||||
|
e2e/test.db
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: Clean.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json bun.lock playwright.config.ts .gitignore
|
||||||
|
git commit -m "chore: install Playwright and add E2E test configuration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: E2E Database Seed and Global Setup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/seed.ts`
|
||||||
|
- Create: `e2e/global-setup.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create seed script**
|
||||||
|
|
||||||
|
Create `e2e/seed.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
import * as schema from "../src/db/schema";
|
||||||
|
|
||||||
|
const DB_PATH = "./e2e/test.db";
|
||||||
|
|
||||||
|
export async function seedTestDatabase() {
|
||||||
|
// Remove old test DB if it exists
|
||||||
|
try {
|
||||||
|
await Bun.file(DB_PATH).exists() &&
|
||||||
|
(await import("node:fs/promises")).then((fs) => fs.unlink(DB_PATH));
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(DB_PATH);
|
||||||
|
sqlite.run("PRAGMA journal_mode = WAL");
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Apply all migrations
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
|
// ── Seed Categories ──
|
||||||
|
const [uncategorized] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Uncategorized", icon: "package" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [shelter] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Shelter", icon: "tent" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [sleep] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Sleep System", icon: "moon" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [cook] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Cook Kit", icon: "flame" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// ── Seed Items ──
|
||||||
|
const tent = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Zpacks Duplex",
|
||||||
|
weightGrams: 539,
|
||||||
|
priceCents: 67900,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
notes: "DCF shelter, 2-person",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const tarp = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Borah Gear Tarp",
|
||||||
|
weightGrams: 156,
|
||||||
|
priceCents: 11000,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const quilt = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Enlightened Equipment Enigma 20",
|
||||||
|
weightGrams: 595,
|
||||||
|
priceCents: 34000,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
notes: "20F quilt",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const pad = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Therm-a-Rest NeoAir XLite",
|
||||||
|
weightGrams: 354,
|
||||||
|
priceCents: 20999,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const stove = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "BRS-3000T Stove",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const pot = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Toaks 750ml Pot",
|
||||||
|
weightGrams: 103,
|
||||||
|
priceCents: 3000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// ── Seed Active Thread with 3 Candidates ──
|
||||||
|
const activeThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "New Backpack",
|
||||||
|
status: "active",
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "ULA Circuit",
|
||||||
|
weightGrams: 1077,
|
||||||
|
priceCents: 27500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Great hip belt\nLarge capacity",
|
||||||
|
cons: "Heavier than competitors",
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Gossamer Gear Mariposa",
|
||||||
|
weightGrams: 737,
|
||||||
|
priceCents: 28500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Very lightweight\nGood ventilation",
|
||||||
|
cons: "Smaller hip belt pockets",
|
||||||
|
sortOrder: 2000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Granite Gear Crown2 38",
|
||||||
|
weightGrams: 850,
|
||||||
|
priceCents: 18000,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
sortOrder: 3000,
|
||||||
|
status: "ordered",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed Resolved Thread ──
|
||||||
|
const resolvedThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "Camp Stove",
|
||||||
|
status: "resolved",
|
||||||
|
categoryId: cook.id,
|
||||||
|
resolvedCandidateId: 1,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: resolvedThread.id,
|
||||||
|
name: "BRS-3000T",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "arrived",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed Setup with Items ──
|
||||||
|
const setup = db
|
||||||
|
.insert(schema.setups)
|
||||||
|
.values({ name: "Weekend Overnighter" })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.setupItems)
|
||||||
|
.values([
|
||||||
|
{ setupId: setup.id, itemId: tent.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: pad.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed User ──
|
||||||
|
const passwordHash = await Bun.password.hash("password123");
|
||||||
|
db.insert(schema.users)
|
||||||
|
.values({ username: "admin", passwordHash })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Seed Settings ──
|
||||||
|
db.insert(schema.settings)
|
||||||
|
.values([
|
||||||
|
{ key: "weightUnit", value: "g" },
|
||||||
|
{ key: "currency", value: "USD" },
|
||||||
|
{ key: "onboardingComplete", value: "true" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
console.log("E2E test database seeded at", DB_PATH);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create global-setup**
|
||||||
|
|
||||||
|
Create `e2e/global-setup.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { seedTestDatabase } from "./seed";
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
await seedTestDatabase();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify seed works**
|
||||||
|
|
||||||
|
Run: `bun run e2e/global-setup.ts`
|
||||||
|
Expected: Prints "E2E test database seeded at ./e2e/test.db" and the file exists.
|
||||||
|
|
||||||
|
Then clean up: `rm -f e2e/test.db`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add e2e/seed.ts e2e/global-setup.ts
|
||||||
|
git commit -m "test: add E2E database seed and Playwright global setup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: E2E Tests — Dashboard and Collection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/dashboard.spec.ts`
|
||||||
|
- Create: `e2e/collection.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create dashboard tests**
|
||||||
|
|
||||||
|
Create `e2e/dashboard.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Dashboard", () => {
|
||||||
|
test("loads and shows summary cards", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("text=GearBox")).toBeVisible();
|
||||||
|
// Should show item count (we seeded 6 items)
|
||||||
|
await expect(page.locator("text=6")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has navigation to collection", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
// Click on a dashboard card or link that goes to collection
|
||||||
|
const collectionLink = page.locator('a[href*="collection"]').first();
|
||||||
|
if (await collectionLink.isVisible()) {
|
||||||
|
await collectionLink.click();
|
||||||
|
await expect(page).toHaveURL(/collection/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create collection tests**
|
||||||
|
|
||||||
|
Create `e2e/collection.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Collection", () => {
|
||||||
|
test("gear tab shows items grouped by category", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
// Should see seeded items
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
await expect(page.locator("text=BRS-3000T Stove")).toBeVisible();
|
||||||
|
// Should see category headers
|
||||||
|
await expect(page.locator("text=Shelter")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Cook Kit")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search filters items by name", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||||
|
await searchInput.fill("Zpacks");
|
||||||
|
// Should show matching item
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
// Should hide non-matching items
|
||||||
|
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab switching works", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to planning tab
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await expect(page.locator("text=Planning Threads")).toBeVisible();
|
||||||
|
await expect(page.locator("text=New Backpack")).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to setups tab
|
||||||
|
await page.goto("/collection?tab=setups");
|
||||||
|
await expect(page.locator("text=Weekend Overnighter")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("category filter dropdown works", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=gear");
|
||||||
|
// Open category filter
|
||||||
|
const filterButton = page.locator("text=All categories");
|
||||||
|
await filterButton.click();
|
||||||
|
// Select "Shelter"
|
||||||
|
await page.locator("li").filter({ hasText: "Shelter" }).click();
|
||||||
|
// Should show only shelter items
|
||||||
|
await expect(page.locator("text=Zpacks Duplex")).toBeVisible();
|
||||||
|
await expect(page.locator("text=BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run E2E tests**
|
||||||
|
|
||||||
|
Run: `bun run test:e2e`
|
||||||
|
Expected: All tests pass. If any fail due to selector issues, adjust selectors based on actual DOM.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add e2e/dashboard.spec.ts e2e/collection.spec.ts
|
||||||
|
git commit -m "test: add E2E tests for dashboard and collection views"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: E2E Tests — Threads, Auth, Error Handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `e2e/threads.spec.ts`
|
||||||
|
- Create: `e2e/auth.spec.ts`
|
||||||
|
- Create: `e2e/error-handling.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create threads tests**
|
||||||
|
|
||||||
|
Create `e2e/threads.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Threads", () => {
|
||||||
|
test("thread detail page shows candidates", async ({ page }) => {
|
||||||
|
// Navigate to the active thread
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.locator("text=New Backpack").click();
|
||||||
|
// Should see candidates
|
||||||
|
await expect(page.locator("text=ULA Circuit")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rank badges are visible on candidates", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.locator("text=New Backpack").click();
|
||||||
|
// Should see rank badges (gold, silver, bronze for top 3)
|
||||||
|
// The rank badges use specific colors: #D4AF37 (gold), #C0C0C0 (silver), #CD7F32 (bronze)
|
||||||
|
await expect(page.locator("text=#1").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comparison view toggles on", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.locator("text=New Backpack").click();
|
||||||
|
// Find and click the compare toggle
|
||||||
|
const compareButton = page.locator("button", { hasText: /compare/i });
|
||||||
|
if (await compareButton.isVisible()) {
|
||||||
|
await compareButton.click();
|
||||||
|
// Comparison table should appear with attribute rows
|
||||||
|
await expect(page.locator("text=Weight")).toBeVisible();
|
||||||
|
await expect(page.locator("text=Price")).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolved thread shows winner", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
// Switch to resolved tab
|
||||||
|
await page.locator("button", { hasText: "Resolved" }).click();
|
||||||
|
await page.locator("text=Camp Stove").click();
|
||||||
|
// Should indicate resolved state
|
||||||
|
await expect(page.locator("text=BRS-3000T")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create auth tests**
|
||||||
|
|
||||||
|
Create `e2e/auth.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Auth", () => {
|
||||||
|
test("login page renders", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.locator("text=Log in")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with valid credentials succeeds", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
|
||||||
|
await page.locator('input[type="password"]').fill("password123");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
// Should redirect away from login
|
||||||
|
await page.waitForURL((url) => !url.pathname.includes("/login"), {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with wrong password shows error", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.locator('input[name="username"], input[placeholder*="sername"]').fill("admin");
|
||||||
|
await page.locator('input[type="password"]').fill("wrongpassword");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
// Should show error message
|
||||||
|
await expect(page.locator("text=Invalid credentials").or(page.locator('[role="alert"]'))).toBeVisible({
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create error handling tests**
|
||||||
|
|
||||||
|
Create `e2e/error-handling.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Error handling", () => {
|
||||||
|
test("non-existent thread shows not found or error", async ({ page }) => {
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
// Should not white-screen — should show some content
|
||||||
|
const body = page.locator("body");
|
||||||
|
await expect(body).not.toBeEmpty();
|
||||||
|
// Either shows error boundary or "not found" text
|
||||||
|
const hasContent = await page
|
||||||
|
.locator("text=Something went wrong")
|
||||||
|
.or(page.locator("text=not found"))
|
||||||
|
.or(page.locator("text=Not Found"))
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
// At minimum, the page should not be blank
|
||||||
|
const bodyText = await body.textContent();
|
||||||
|
expect(bodyText?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-existent setup shows not found or error", async ({ page }) => {
|
||||||
|
await page.goto("/setups/99999");
|
||||||
|
const body = page.locator("body");
|
||||||
|
await expect(body).not.toBeEmpty();
|
||||||
|
const bodyText = await body.textContent();
|
||||||
|
expect(bodyText?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app recovers from navigation errors", async ({ page }) => {
|
||||||
|
// Navigate to a bad route, then back to a good one
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
await page.goto("/");
|
||||||
|
// Dashboard should load fine
|
||||||
|
await expect(page.locator("text=GearBox")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all E2E tests**
|
||||||
|
|
||||||
|
Run: `bun run test:e2e`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add e2e/threads.spec.ts e2e/auth.spec.ts e2e/error-handling.spec.ts
|
||||||
|
git commit -m "test: add E2E tests for threads, auth, and error handling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Final Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run unit tests**
|
||||||
|
|
||||||
|
Run: `bun test`
|
||||||
|
Expected: All tests pass (previous 183 + new parseId + rate limiter + param routes).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run E2E tests**
|
||||||
|
|
||||||
|
Run: `bun run test:e2e`
|
||||||
|
Expected: All E2E tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint**
|
||||||
|
|
||||||
|
Run: `bun run lint`
|
||||||
|
Expected: Clean.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Code Quality Improvements (Round 2) Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-03
|
||||||
|
**Scope:** Combined formatters hook, test helper schema generation, stale todo cleanup
|
||||||
|
|
||||||
|
## 1. useFormatters Combined Hook
|
||||||
|
|
||||||
|
**Problem:** 14 component files import the same 3-4 lines: `useWeightUnit`, `useCurrency`, `formatWeight`, `formatPrice`. This is repetitive boilerplate.
|
||||||
|
|
||||||
|
**Solution:** Create `src/client/hooks/useFormatters.ts` that returns pre-bound formatting functions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useFormatters() {
|
||||||
|
const unit = useWeightUnit();
|
||||||
|
const currency = useCurrency();
|
||||||
|
return {
|
||||||
|
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||||
|
price: (cents: number | null) => formatPrice(cents, currency),
|
||||||
|
unit,
|
||||||
|
currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Consumer files to update (14):**
|
||||||
|
- CollectionView.tsx
|
||||||
|
- setups/$setupId.tsx
|
||||||
|
- routes/index.tsx
|
||||||
|
- WeightSummaryCard.tsx
|
||||||
|
- TotalsBar.tsx
|
||||||
|
- settings.tsx
|
||||||
|
- ThreadCard.tsx
|
||||||
|
- SetupCard.tsx
|
||||||
|
- ItemPicker.tsx
|
||||||
|
- ItemCard.tsx
|
||||||
|
- ComparisonTable.tsx
|
||||||
|
- CandidateCard.tsx
|
||||||
|
- CandidateListItem.tsx
|
||||||
|
- CategoryHeader.tsx
|
||||||
|
|
||||||
|
Each file replaces 3-4 imports + 2 hook calls with 1 import + 1 destructured hook call. Components that need raw `unit` or `currency` (e.g., WeightSummaryCard uses `unit` as a type, TotalsBar has a unit toggle) get them from the return object.
|
||||||
|
|
||||||
|
## 2. Test Helper Schema Generation
|
||||||
|
|
||||||
|
**Problem:** `tests/helpers/db.ts` has 120 lines of hand-written CREATE TABLE SQL that must manually mirror `src/db/schema.ts`. Any schema change requires updating both files — a known source of `SqliteError: no such column` failures.
|
||||||
|
|
||||||
|
**Solution:** Replace hand-written SQL with Drizzle's migration runner:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
|
||||||
|
export function createTestDb() {
|
||||||
|
const sqlite = new Database(":memory:");
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
db.insert(schema.categories).values({ name: "Uncategorized", icon: "package" }).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This reduces the file from ~128 lines to ~15 lines and eliminates all future manual sync.
|
||||||
|
|
||||||
|
## 3. Stale Todo Cleanup
|
||||||
|
|
||||||
|
**Problem:** Pending todo "Replace planning category filter select with icon-aware dropdown" from 2026-03-15 is already resolved — `PlanningView.tsx` uses `<CategoryFilterDropdown>` which renders Lucide icons.
|
||||||
|
|
||||||
|
**Solution:** Move the todo file from `pending/` to `done/`.
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
1. **useFormatters hook** — create hook + update all 14 consumer files
|
||||||
|
2. **Test helper migration** — replace hand-written SQL with migrate()
|
||||||
|
3. **Todo cleanup** — move stale todo to done
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Codebase Improvements Design
|
||||||
|
|
||||||
|
**Date:** 2026-04--03
|
||||||
|
**Scope:** General code quality, error handling, resilience, and maintainability improvements
|
||||||
|
|
||||||
|
## 1. Server Hardening
|
||||||
|
|
||||||
|
### 1a. Explicit DB Context Middleware
|
||||||
|
|
||||||
|
**File:** `src/server/index.ts`
|
||||||
|
|
||||||
|
Add middleware that explicitly sets `c.set("db", prodDb)` for all API routes. Currently routes call `c.get("db")` but nothing sets it in production — services silently fall back to `prodDb` via default parameters. This makes production behavior match the test pattern.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db as prodDb } from "../db/index.ts";
|
||||||
|
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
c.set("db", prodDb);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Place this **before** the auth middleware so `db` is available when auth checks run.
|
||||||
|
|
||||||
|
### 1b. Route Parameter Validation
|
||||||
|
|
||||||
|
**New file:** `src/server/lib/params.ts`
|
||||||
|
|
||||||
|
Create a helper that validates numeric route params:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function parseId(raw: string): number | null {
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update all route files (`items.ts`, `threads.ts`, `categories.ts`, `setups.ts`) to replace `Number(c.req.param("id"))` with `parseId()`, returning 400 for invalid IDs.
|
||||||
|
|
||||||
|
### 1c. Centralized Error Handling
|
||||||
|
|
||||||
|
**File:** `src/server/index.ts`
|
||||||
|
|
||||||
|
Add Hono's `onError` handler:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||||
|
const status = err instanceof HTTPException ? err.status : 500;
|
||||||
|
const message = process.env.NODE_ENV === "production"
|
||||||
|
? "Internal server error"
|
||||||
|
: err.message;
|
||||||
|
return c.json({ error: message }, status);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1d. Auth Comment Fix
|
||||||
|
|
||||||
|
**File:** `src/server/index.ts`
|
||||||
|
|
||||||
|
Change comment from:
|
||||||
|
```
|
||||||
|
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1e. Rate Limiting on Auth Endpoints
|
||||||
|
|
||||||
|
**New file:** `src/server/middleware/rateLimit.ts`
|
||||||
|
|
||||||
|
In-memory rate limiter using a `Map<string, { count: number; resetAt: number }>`:
|
||||||
|
|
||||||
|
- Tracks by IP (`c.req.header("x-forwarded-for") || "unknown"`)
|
||||||
|
- 5 attempts per 15-minute window
|
||||||
|
- Returns 429 with `{ error: "Too many attempts. Try again later." }` and `Retry-After` header
|
||||||
|
- Stale entries cleaned on each check
|
||||||
|
- Applied to `POST /api/auth/login` and `POST /api/auth/setup`
|
||||||
|
|
||||||
|
## 2. Client Resilience
|
||||||
|
|
||||||
|
### Error Boundary
|
||||||
|
|
||||||
|
**File:** `src/client/routes/__root.tsx`
|
||||||
|
|
||||||
|
Add `errorComponent` to the root route definition:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
|
errorComponent: RootErrorBoundary,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`RootErrorBoundary` renders a centered error message with:
|
||||||
|
- "Something went wrong" heading
|
||||||
|
- Error message in dev mode
|
||||||
|
- "Try again" button that calls `router.invalidate()` + `reset()`
|
||||||
|
|
||||||
|
Uses TanStack Router's `ErrorComponentProps` which provides `error` and `reset`.
|
||||||
|
|
||||||
|
## 3. Client Refactor
|
||||||
|
|
||||||
|
### Split collection/index.tsx
|
||||||
|
|
||||||
|
Extract the three tab-level functions into separate component files:
|
||||||
|
|
||||||
|
| Source function | New file | Approx lines |
|
||||||
|
|----------------|----------|-------------|
|
||||||
|
| `CollectionView()` | `src/client/components/CollectionView.tsx` | ~260 |
|
||||||
|
| `PlanningView()` | `src/client/components/PlanningView.tsx` | ~190 |
|
||||||
|
| `SetupsView()` | `src/client/components/SetupsView.tsx` | ~110 |
|
||||||
|
|
||||||
|
`collection/index.tsx` keeps:
|
||||||
|
- Route definition with `searchSchema` and `validateSearch`
|
||||||
|
- `CollectionPage` function (tab switcher + AnimatePresence)
|
||||||
|
- `TAB_ORDER` and `slideVariants` constants
|
||||||
|
- Imports from the three new component files
|
||||||
|
|
||||||
|
Each extracted component is a named export, self-contained with its own hooks and local state.
|
||||||
|
|
||||||
|
## 4. Docs Cleanup
|
||||||
|
|
||||||
|
### PROJECT.md
|
||||||
|
|
||||||
|
**File:** `.planning/PROJECT.md`
|
||||||
|
|
||||||
|
Update Constraints section line:
|
||||||
|
```
|
||||||
|
- **Scope**: No auth, single user for v1
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
- **Scope**: Single user with cookie/API key auth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
Group into 3-4 commits by area:
|
||||||
|
1. **Server hardening**: DB middleware, param validation, error handler, rate limiter, comment fix
|
||||||
|
2. **Client resilience + refactor**: Error boundary, split collection route
|
||||||
|
3. **Docs cleanup**: PROJECT.md update
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- All 183 existing tests must continue to pass
|
||||||
|
- Rate limiter: manual verification (no automated test needed for in-memory rate limiting in a single-user app)
|
||||||
|
- Error boundary: manual verification by triggering a render error
|
||||||
|
- Param validation: existing route tests cover happy paths; invalid IDs are a new edge case but won't break existing tests
|
||||||
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
128
docs/superpowers/specs/2026-04-03-testing-improvements-design.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Testing Improvements Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-03
|
||||||
|
**Scope:** Unit tests for new server code + Playwright E2E test setup with seeded database
|
||||||
|
|
||||||
|
## Part 1: Unit/Integration Tests (Bun test runner)
|
||||||
|
|
||||||
|
### tests/lib/params.test.ts
|
||||||
|
|
||||||
|
Tests for `parseId` helper in `src/server/lib/params.ts`:
|
||||||
|
- Valid positive integers (1, 42, 999) return the number
|
||||||
|
- Zero returns null
|
||||||
|
- Negative numbers (-1, -100) return null
|
||||||
|
- Decimals (1.5, 3.14) return null
|
||||||
|
- Non-numeric strings ("abc", "", "hello") return null
|
||||||
|
- NaN-producing values return null
|
||||||
|
|
||||||
|
### tests/middleware/rateLimit.test.ts
|
||||||
|
|
||||||
|
Tests for rate limiter in `src/server/middleware/rateLimit.ts`:
|
||||||
|
- First request passes through (200)
|
||||||
|
- 5 requests succeed, 6th returns 429
|
||||||
|
- 429 response includes `Retry-After` header
|
||||||
|
- Different IPs tracked independently
|
||||||
|
- After window expires, requests succeed again
|
||||||
|
|
||||||
|
Since the rate limiter uses a module-level `Map`, tests need to either:
|
||||||
|
- Reset the store between tests (export a `resetStore` for testing), OR
|
||||||
|
- Use unique paths/IPs per test to avoid interference
|
||||||
|
|
||||||
|
Recommended: export a `_resetForTesting()` function from rateLimit.ts that clears the store. Only used in tests.
|
||||||
|
|
||||||
|
### tests/routes/params.test.ts
|
||||||
|
|
||||||
|
Route-level integration tests verifying 400 responses for invalid IDs:
|
||||||
|
- `GET /api/items/abc` → 400
|
||||||
|
- `GET /api/items/-1` → 400
|
||||||
|
- `GET /api/items/0` → 400
|
||||||
|
- `DELETE /api/categories/notanumber` → 400
|
||||||
|
- `GET /api/threads/abc` → 400
|
||||||
|
- `GET /api/setups/abc` → 400
|
||||||
|
|
||||||
|
Uses existing test app pattern with in-memory DB.
|
||||||
|
|
||||||
|
## Part 2: Playwright E2E Setup
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
- `bun add -d @playwright/test`
|
||||||
|
- `bunx playwright install chromium` (only Chromium needed)
|
||||||
|
|
||||||
|
### Configuration: playwright.config.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
webServer: {
|
||||||
|
command: "DATABASE_PATH=./e2e/test.db bun run dev:server",
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Seeding: e2e/seed.ts
|
||||||
|
|
||||||
|
Script that creates `e2e/test.db` with:
|
||||||
|
- Run Drizzle migrations against the file
|
||||||
|
- Seed data:
|
||||||
|
- 1 user (username: "admin", password: "password123")
|
||||||
|
- 3 categories: Shelter, Sleep System, Cook Kit
|
||||||
|
- 6 items across categories with realistic weights/prices
|
||||||
|
- 1 active thread with 3 candidates (with pros/cons, sort_order)
|
||||||
|
- 1 resolved thread
|
||||||
|
- 1 setup with 4 items (mixed classifications)
|
||||||
|
- Settings: weightUnit=g, currency=USD, onboardingComplete=true
|
||||||
|
|
||||||
|
Run before E2E tests via `e2e/global-setup.ts` (Playwright globalSetup).
|
||||||
|
|
||||||
|
### E2E Test Files
|
||||||
|
|
||||||
|
**e2e/dashboard.spec.ts**
|
||||||
|
- Dashboard page loads
|
||||||
|
- Summary cards show item count, weight, cost
|
||||||
|
- Navigation links to collection work
|
||||||
|
|
||||||
|
**e2e/collection.spec.ts**
|
||||||
|
- Gear tab renders items grouped by category
|
||||||
|
- Search input filters items by name
|
||||||
|
- Category filter dropdown works
|
||||||
|
- Tab switching between gear/planning/setups
|
||||||
|
|
||||||
|
**e2e/threads.spec.ts**
|
||||||
|
- Thread detail page loads with candidates
|
||||||
|
- Comparison view toggle works (shows table)
|
||||||
|
- Rank badges visible on candidates
|
||||||
|
|
||||||
|
**e2e/auth.spec.ts**
|
||||||
|
- Login page renders
|
||||||
|
- Login with valid credentials succeeds
|
||||||
|
- Login with wrong password shows error
|
||||||
|
- Rate limiting returns error after 5 attempts
|
||||||
|
|
||||||
|
**e2e/error-boundary.spec.ts**
|
||||||
|
- App doesn't white-screen on unknown routes
|
||||||
|
- Navigating to a non-existent thread/setup shows appropriate error
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
Add to package.json:
|
||||||
|
- `"test:e2e": "bunx playwright test"`
|
||||||
|
- `"test:e2e:ui": "bunx playwright test --ui"` (for debugging)
|
||||||
|
|
||||||
|
### Files to .gitignore
|
||||||
|
|
||||||
|
- `e2e/test.db`
|
||||||
|
- `test-results/`
|
||||||
|
- `playwright-report/`
|
||||||
|
|
||||||
|
## Commit Strategy
|
||||||
|
|
||||||
|
1. Unit tests for parseId, rate limiter, route params
|
||||||
|
2. Playwright setup (install, config, seed, global-setup)
|
||||||
|
3. Playwright E2E test files
|
||||||
47
e2e/auth.spec.ts
Normal file
47
e2e/auth.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Authentication", () => {
|
||||||
|
test("login page renders at /login", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Should show the Sign In heading
|
||||||
|
await expect(page.getByRole("heading", { name: "Sign In" })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have username and password inputs
|
||||||
|
await expect(page.locator("#username")).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator("#password")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with valid credentials succeeds and redirects away from /login", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("#username").fill("admin");
|
||||||
|
await page.locator("#password").fill("password123");
|
||||||
|
await page.getByRole("button", { name: "Sign In" }).click();
|
||||||
|
|
||||||
|
// After successful login, should redirect to / (dashboard)
|
||||||
|
await page.waitForURL("/", { timeout: 5000 });
|
||||||
|
await expect(page).not.toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login with wrong password shows error", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("#username").fill("admin");
|
||||||
|
await page.locator("#password").fill("wrongpassword");
|
||||||
|
await page.getByRole("button", { name: "Sign In" }).click();
|
||||||
|
|
||||||
|
// Should stay on the login page and show an error message
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 5000 });
|
||||||
|
// The error paragraph should be visible (login.tsx renders <p className="text-sm text-red-600">{error}</p>)
|
||||||
|
await expect(page.locator(".text-red-600")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
89
e2e/collection.spec.ts
Normal file
89
e2e/collection.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Collection page", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/collection");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Gear tab", () => {
|
||||||
|
test("shows seeded items", async ({ page }) => {
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search filters items by name", async ({ page }) => {
|
||||||
|
const searchInput = page.getByPlaceholder("Search items...");
|
||||||
|
await searchInput.fill("Zpacks");
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
// Other items should not be visible
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing search restores all items", async ({ page }) => {
|
||||||
|
const searchInput = page.getByPlaceholder("Search items...");
|
||||||
|
await searchInput.fill("Zpacks");
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
// Clear the search
|
||||||
|
await searchInput.clear();
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("category filter dropdown opens and lists categories", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const filterButton = page.getByRole("button", {
|
||||||
|
name: /all categories/i,
|
||||||
|
});
|
||||||
|
await filterButton.click();
|
||||||
|
|
||||||
|
// Dropdown list (ul) contains the category options
|
||||||
|
const dropdown = page.locator("ul");
|
||||||
|
await expect(
|
||||||
|
dropdown.getByRole("button", { name: "Shelter" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
dropdown.getByRole("button", { name: "Cook Kit" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("category filter shows only items in selected category", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Open filter dropdown
|
||||||
|
const filterButton = page.getByRole("button", {
|
||||||
|
name: /all categories/i,
|
||||||
|
});
|
||||||
|
await filterButton.click();
|
||||||
|
|
||||||
|
// Select "Shelter" from the dropdown list
|
||||||
|
const dropdown = page.locator("ul");
|
||||||
|
await dropdown.getByRole("button", { name: "Shelter" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
// Items from other categories should not be visible
|
||||||
|
await expect(page.getByText("BRS-3000T Stove")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Tab switching", () => {
|
||||||
|
test("navigates to planning tab", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
// Planning tab shows the active thread
|
||||||
|
await expect(page.getByText("New Backpack")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigates to setups tab", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=setups");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
// Setups tab shows the seeded setup
|
||||||
|
await expect(page.getByText("Weekend Overnighter")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gear tab is default and shows items", async ({ page }) => {
|
||||||
|
// Default tab (no ?tab param) shows gear
|
||||||
|
await expect(page.getByText("Zpacks Duplex")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
e2e/dashboard.spec.ts
Normal file
56
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Dashboard", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows GearBox heading", async ({ page }) => {
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows collection card with item count of 6", async ({ page }) => {
|
||||||
|
// The Collection card link contains "Items" label and value "6"
|
||||||
|
const collectionCard = page
|
||||||
|
.getByRole("link", { name: /collection/i })
|
||||||
|
.first();
|
||||||
|
await expect(collectionCard).toBeVisible();
|
||||||
|
await expect(collectionCard.getByText("6")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows Collection, Planning, and Setups card headings", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Collection" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Planning" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Collection card links to /collection", async ({ page }) => {
|
||||||
|
const collectionLink = page
|
||||||
|
.getByRole("link", { name: /collection/i })
|
||||||
|
.first();
|
||||||
|
await collectionLink.click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page).toHaveURL(/\/collection/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows active thread count on Planning card", async ({ page }) => {
|
||||||
|
// The Planning card is a link containing "Active threads"
|
||||||
|
const planningCard = page.getByRole("link", { name: /planning/i });
|
||||||
|
await expect(planningCard.getByText("Active threads")).toBeVisible();
|
||||||
|
// Seed has 1 active thread
|
||||||
|
await expect(planningCard.getByText("1")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows setup count on Setups card", async ({ page }) => {
|
||||||
|
// The Setups card has a heading "Setups"
|
||||||
|
await expect(page.getByRole("heading", { name: "Setups" })).toBeVisible();
|
||||||
|
// Seed has 1 setup
|
||||||
|
const setupsCard = page.getByRole("link", { name: /setups/i }).last();
|
||||||
|
await expect(setupsCard.getByText("1")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
59
e2e/error-handling.spec.ts
Normal file
59
e2e/error-handling.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Error handling — non-existent routes", () => {
|
||||||
|
test("non-existent thread does not white-screen", async ({ page }) => {
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
|
||||||
|
// React Query retries failed requests (default 3 times with backoff) before
|
||||||
|
// setting isError=true. Wait for the page to leave loading state and show content.
|
||||||
|
// The thread detail page renders "Thread not found" + "Back to planning" link on error.
|
||||||
|
await expect(page.getByText("Back to planning")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("Thread not found")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-existent setup does not white-screen", async ({ page }) => {
|
||||||
|
await page.goto("/setups/99999");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Setup detail shows "Setup not found." when data is null (no retry — isLoading=false, data=undefined)
|
||||||
|
// The setup query resolves with undefined rather than throwing for missing items.
|
||||||
|
// Check that the page has content (not a blank screen).
|
||||||
|
const body = page.locator("body");
|
||||||
|
await expect(body).not.toBeEmpty();
|
||||||
|
|
||||||
|
// Navigation header should be visible (app did not crash)
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Wait for setup data to load; it will show "Setup not found." when done
|
||||||
|
await expect(page.getByText("Setup not found.")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app recovers after bad route — dashboard loads fine", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Navigate to a non-existent thread first
|
||||||
|
await page.goto("/threads/99999");
|
||||||
|
|
||||||
|
// The page should render without crashing — wait for the error state
|
||||||
|
// (React Query retries before showing isError state, so use a long timeout)
|
||||||
|
await expect(page.getByText("Back to planning")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now navigate to the dashboard
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Dashboard should load normally
|
||||||
|
await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.getByRole("heading", { name: "Collection" })).toBeVisible(
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
e2e/global-setup.ts
Normal file
10
e2e/global-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { seedTestDatabase } from "./seed";
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
await seedTestDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow direct invocation: bun run e2e/global-setup.ts
|
||||||
|
if (import.meta.main) {
|
||||||
|
await globalSetup();
|
||||||
|
}
|
||||||
220
e2e/seed.ts
Normal file
220
e2e/seed.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
|
import * as schema from "../src/db/schema";
|
||||||
|
|
||||||
|
const DB_PATH = "./e2e/test.db";
|
||||||
|
|
||||||
|
export async function seedTestDatabase() {
|
||||||
|
// Remove old test DB if it exists
|
||||||
|
try {
|
||||||
|
await unlink(DB_PATH);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(DB_PATH);
|
||||||
|
sqlite.run("PRAGMA journal_mode = WAL");
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
|
// ── Categories ──
|
||||||
|
const [uncategorized] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Uncategorized", icon: "package" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [shelter] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Shelter", icon: "tent" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [sleep] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Sleep System", icon: "moon" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const [cook] = db
|
||||||
|
.insert(schema.categories)
|
||||||
|
.values({ name: "Cook Kit", icon: "flame" })
|
||||||
|
.returning()
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// ── Items ──
|
||||||
|
const tent = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Zpacks Duplex",
|
||||||
|
weightGrams: 539,
|
||||||
|
priceCents: 67900,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
notes: "DCF shelter, 2-person",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Borah Gear Tarp",
|
||||||
|
weightGrams: 156,
|
||||||
|
priceCents: 11000,
|
||||||
|
categoryId: shelter.id,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const quilt = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Enlightened Equipment Enigma 20",
|
||||||
|
weightGrams: 595,
|
||||||
|
priceCents: 34000,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
notes: "20F quilt",
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const pad = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Therm-a-Rest NeoAir XLite",
|
||||||
|
weightGrams: 354,
|
||||||
|
priceCents: 20999,
|
||||||
|
categoryId: sleep.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const stove = db
|
||||||
|
.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "BRS-3000T Stove",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.items)
|
||||||
|
.values({
|
||||||
|
name: "Toaks 750ml Pot",
|
||||||
|
weightGrams: 103,
|
||||||
|
priceCents: 3000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Active Thread with 3 Candidates ──
|
||||||
|
const activeThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "New Backpack",
|
||||||
|
status: "active",
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "ULA Circuit",
|
||||||
|
weightGrams: 1077,
|
||||||
|
priceCents: 27500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Great hip belt\nLarge capacity",
|
||||||
|
cons: "Heavier than competitors",
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Gossamer Gear Mariposa",
|
||||||
|
weightGrams: 737,
|
||||||
|
priceCents: 28500,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
pros: "Very lightweight\nGood ventilation",
|
||||||
|
cons: "Smaller hip belt pockets",
|
||||||
|
sortOrder: 2000,
|
||||||
|
status: "researching",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: activeThread.id,
|
||||||
|
name: "Granite Gear Crown2 38",
|
||||||
|
weightGrams: 850,
|
||||||
|
priceCents: 18000,
|
||||||
|
categoryId: uncategorized.id,
|
||||||
|
sortOrder: 3000,
|
||||||
|
status: "ordered",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Resolved Thread ──
|
||||||
|
const resolvedThread = db
|
||||||
|
.insert(schema.threads)
|
||||||
|
.values({
|
||||||
|
name: "Camp Stove",
|
||||||
|
status: "resolved",
|
||||||
|
categoryId: cook.id,
|
||||||
|
resolvedCandidateId: 1,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.threadCandidates)
|
||||||
|
.values({
|
||||||
|
threadId: resolvedThread.id,
|
||||||
|
name: "BRS-3000T",
|
||||||
|
weightGrams: 25,
|
||||||
|
priceCents: 2000,
|
||||||
|
categoryId: cook.id,
|
||||||
|
sortOrder: 1000,
|
||||||
|
status: "arrived",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── Setup with Items ──
|
||||||
|
const setup = db
|
||||||
|
.insert(schema.setups)
|
||||||
|
.values({ name: "Weekend Overnighter" })
|
||||||
|
.returning()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
db.insert(schema.setupItems)
|
||||||
|
.values([
|
||||||
|
{ setupId: setup.id, itemId: tent.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: quilt.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: pad.id, classification: "base" },
|
||||||
|
{ setupId: setup.id, itemId: stove.id, classification: "consumable" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// ── User ──
|
||||||
|
const passwordHash = await Bun.password.hash("password123");
|
||||||
|
db.insert(schema.users).values({ username: "admin", passwordHash }).run();
|
||||||
|
|
||||||
|
// ── Settings ──
|
||||||
|
db.insert(schema.settings)
|
||||||
|
.values([
|
||||||
|
{ key: "weightUnit", value: "g" },
|
||||||
|
{ key: "currency", value: "USD" },
|
||||||
|
{ key: "onboardingComplete", value: "true" },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
console.log("E2E test database seeded at", DB_PATH);
|
||||||
|
}
|
||||||
4
e2e/start-test-server.sh
Executable file
4
e2e/start-test-server.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Seed the test database, then start the production server
|
||||||
|
bun run e2e/global-setup.ts
|
||||||
|
NODE_ENV=production DATABASE_PATH=./e2e/test.db bun run src/server/index.ts
|
||||||
113
e2e/threads.spec.ts
Normal file
113
e2e/threads.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Thread detail page", () => {
|
||||||
|
test("loads with candidates visible", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Click the "New Backpack" thread card
|
||||||
|
await page.getByText("New Backpack").click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Thread detail page should show the thread name
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "New Backpack" }),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Candidates should be visible
|
||||||
|
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.getByText("Gossamer Gear Mariposa")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("Granite Gear Crown2 38")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rank badges are visible for top 3 candidates", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.getByText("New Backpack").click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Rank badges are medal icons — the component renders LucideIcon with name="medal"
|
||||||
|
// for ranks 1, 2, 3. We can verify via SVG elements or by checking the page has
|
||||||
|
// the expected number of medal icons (one per top candidate).
|
||||||
|
// The list view is default, which renders CandidateListItem with RankBadge.
|
||||||
|
// With 3 candidates all in top 3, all 3 get medal icons.
|
||||||
|
await expect(page.locator("text=ULA Circuit")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify candidates are rendered (rank badges are SVG medals, not text)
|
||||||
|
// Check that at least the 3 candidates are present in the list
|
||||||
|
const candidateRows = page.locator(".bg-white.rounded-xl.border");
|
||||||
|
await expect(candidateRows).toHaveCount(3, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comparison view toggle works", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.getByText("New Backpack").click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// The compare button is a button with title="Compare view" (icon button with columns-3 icon)
|
||||||
|
const compareButton = page.getByRole("button", { name: "Compare view" });
|
||||||
|
await expect(compareButton).toBeVisible({ timeout: 5000 });
|
||||||
|
await compareButton.click();
|
||||||
|
|
||||||
|
// After clicking, a table should appear with Weight and Price row labels
|
||||||
|
await expect(page.locator("table")).toBeVisible({ timeout: 5000 });
|
||||||
|
// The comparison table renders row labels in sticky <td> cells (exact match to avoid
|
||||||
|
// matching candidate notes that contain the word "weight" or "price")
|
||||||
|
await expect(
|
||||||
|
page.getByRole("cell", { name: "Weight", exact: true }),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(
|
||||||
|
page.getByRole("cell", { name: "Price", exact: true }),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// All 3 candidates should appear as table column headers (in <thead>)
|
||||||
|
await expect(page.locator("thead").getByText("ULA Circuit")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.locator("thead").getByText("Gossamer Gear Mariposa"),
|
||||||
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolved thread shows winner banner", async ({ page }) => {
|
||||||
|
await page.goto("/collection?tab=planning");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Click the "Resolved" tab pill button in the planning view
|
||||||
|
await page.getByRole("button", { name: "Resolved" }).click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Camp Stove resolved thread should appear
|
||||||
|
await expect(page.getByText("Camp Stove")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the Camp Stove thread card and wait for URL to change to thread detail
|
||||||
|
await page.getByText("Camp Stove").click();
|
||||||
|
await page.waitForURL(/\/threads\/\d+/, { timeout: 5000 });
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Should show the resolved thread heading
|
||||||
|
await expect(page.getByRole("heading", { name: "Camp Stove" })).toBeVisible(
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// The winner candidate (BRS-3000T) should be visible in the candidate list
|
||||||
|
await expect(page.getByText("BRS-3000T")).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,11 +10,14 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
"db:push": "bunx drizzle-kit push",
|
"db:push": "bunx drizzle-kit push",
|
||||||
"test": "bun test",
|
"test": "bun test tests/",
|
||||||
|
"test:e2e": "bunx playwright test",
|
||||||
|
"test:e2e:ui": "bunx playwright test --ui",
|
||||||
"lint": "bunx @biomejs/biome check ."
|
"lint": "bunx @biomejs/biome check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.7",
|
"@biomejs/biome": "^2.4.7",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-router-devtools": "^1.166.7",
|
"@tanstack/react-router-devtools": "^1.166.7",
|
||||||
"@tanstack/router-plugin": "^1.166.9",
|
"@tanstack/router-plugin": "^1.166.9",
|
||||||
|
|||||||
25
playwright.config.ts
Normal file
25
playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "sh e2e/start-test-server.sh",
|
||||||
|
port: 3000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { RankBadge } from "./CandidateListItem";
|
import { RankBadge } from "./CandidateListItem";
|
||||||
@@ -41,8 +39,7 @@ export function CandidateCard({
|
|||||||
cons,
|
cons,
|
||||||
rank,
|
rank,
|
||||||
}: CandidateCardProps) {
|
}: CandidateCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
const openConfirmDeleteCandidate = useUIStore(
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
(s) => s.openConfirmDeleteCandidate,
|
(s) => s.openConfirmDeleteCandidate,
|
||||||
@@ -165,12 +162,12 @@ export function CandidateCard({
|
|||||||
{rank != null && <RankBadge rank={rank} />}
|
{rank != null && <RankBadge rank={rank} />}
|
||||||
{weightGrams != null && (
|
{weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(weightGrams, unit)}
|
{weight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{priceCents != null && (
|
{priceCents != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(priceCents, currency)}
|
{price(priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Reorder, useDragControls } from "framer-motion";
|
import { Reorder, useDragControls } from "framer-motion";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
@@ -53,8 +51,7 @@ export function CandidateListItem({
|
|||||||
onStatusChange,
|
onStatusChange,
|
||||||
}: CandidateListItemProps) {
|
}: CandidateListItemProps) {
|
||||||
const controls = useDragControls();
|
const controls = useDragControls();
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
const openConfirmDeleteCandidate = useUIStore(
|
const openConfirmDeleteCandidate = useUIStore(
|
||||||
(s) => s.openConfirmDeleteCandidate,
|
(s) => s.openConfirmDeleteCandidate,
|
||||||
@@ -62,13 +59,11 @@ export function CandidateListItem({
|
|||||||
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
const openResolveDialog = useUIStore((s) => s.openResolveDialog);
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
|
||||||
return (
|
const sharedClassName =
|
||||||
<Reorder.Item
|
"flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default";
|
||||||
value={candidate}
|
|
||||||
dragControls={controls}
|
const innerContent = (
|
||||||
dragListener={false}
|
<>
|
||||||
className="flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default"
|
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
@@ -113,12 +108,12 @@ export function CandidateListItem({
|
|||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
{candidate.weightGrams != null && (
|
{candidate.weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(candidate.weightGrams, unit)}
|
{weight(candidate.weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{candidate.priceCents != null && (
|
{candidate.priceCents != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(candidate.priceCents, currency)}
|
{price(candidate.priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
@@ -206,6 +201,22 @@ export function CandidateListItem({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder.Item requires a Reorder.Group parent — only use it in active threads
|
||||||
|
if (isActive) {
|
||||||
|
return (
|
||||||
|
<Reorder.Item
|
||||||
|
value={candidate}
|
||||||
|
dragControls={controls}
|
||||||
|
dragListener={false}
|
||||||
|
className={sharedClassName}
|
||||||
|
>
|
||||||
|
{innerContent}
|
||||||
</Reorder.Item>
|
</Reorder.Item>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={sharedClassName}>{innerContent}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { IconPicker } from "./IconPicker";
|
import { IconPicker } from "./IconPicker";
|
||||||
|
|
||||||
@@ -23,8 +21,7 @@ export function CategoryHeader({
|
|||||||
totalCost,
|
totalCost,
|
||||||
itemCount,
|
itemCount,
|
||||||
}: CategoryHeaderProps) {
|
}: CategoryHeaderProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editName, setEditName] = useState(name);
|
const [editName, setEditName] = useState(name);
|
||||||
const [editIcon, setEditIcon] = useState(icon);
|
const [editIcon, setEditIcon] = useState(icon);
|
||||||
@@ -88,8 +85,8 @@ export function CategoryHeader({
|
|||||||
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
<LucideIcon name={icon} size={22} className="text-gray-500" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
|
{itemCount} {itemCount === 1 ? "item" : "items"} · {weight(totalWeight)}{" "}
|
||||||
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
|
· {price(totalCost)}
|
||||||
</span>
|
</span>
|
||||||
{!isUncategorized && (
|
{!isUncategorized && (
|
||||||
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
|||||||
274
src/client/components/CollectionView.tsx
Normal file
274
src/client/components/CollectionView.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import { useItems } from "../hooks/useItems";
|
||||||
|
import { useTotals } from "../hooks/useTotals";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CategoryHeader } from "./CategoryHeader";
|
||||||
|
import { ItemCard } from "./ItemCard";
|
||||||
|
|
||||||
|
export function CollectionView() {
|
||||||
|
const { data: items, isLoading: itemsLoading } = useItems();
|
||||||
|
const { data: totals } = useTotals();
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { weight, price } = useFormatters();
|
||||||
|
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!items) return [];
|
||||||
|
return items.filter((item) => {
|
||||||
|
const matchesSearch =
|
||||||
|
searchText === "" ||
|
||||||
|
item.name.toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
const matchesCategory =
|
||||||
|
categoryFilter === null || item.categoryId === categoryFilter;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
}, [items, searchText, categoryFilter]);
|
||||||
|
|
||||||
|
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
|
||||||
|
|
||||||
|
if (itemsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-48" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="mb-4">
|
||||||
|
<LucideIcon
|
||||||
|
name="backpack"
|
||||||
|
size={48}
|
||||||
|
className="text-gray-400 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Your collection is empty
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Start cataloging your gear by adding your first item. Track weight,
|
||||||
|
price, and organize by category.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openAddPanel}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Add your first item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build category totals lookup
|
||||||
|
const categoryTotalsMap = new Map<
|
||||||
|
number,
|
||||||
|
{ totalWeight: number; totalCost: number; itemCount: number }
|
||||||
|
>();
|
||||||
|
if (totals?.categories) {
|
||||||
|
for (const ct of totals.categories) {
|
||||||
|
categoryTotalsMap.set(ct.categoryId, {
|
||||||
|
totalWeight: ct.totalWeight,
|
||||||
|
totalCost: ct.totalCost,
|
||||||
|
itemCount: ct.itemCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group filtered items by categoryId (used when no active filters)
|
||||||
|
const groupedItems = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
items: typeof filteredItems;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const item of filteredItems) {
|
||||||
|
const group = groupedItems.get(item.categoryId);
|
||||||
|
if (group) {
|
||||||
|
group.items.push(item);
|
||||||
|
} else {
|
||||||
|
groupedItems.set(item.categoryId, {
|
||||||
|
items: [item],
|
||||||
|
categoryName: item.categoryName,
|
||||||
|
categoryIcon: item.categoryIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Collection stats card */}
|
||||||
|
{totals?.global && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-500">Items</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{totals.global.itemCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-500">Total Weight</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{weight(totals.global.totalWeight)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<LucideIcon
|
||||||
|
name="credit-card"
|
||||||
|
size={14}
|
||||||
|
className="text-gray-400"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">Total Spent</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{price(totals.global.totalCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search/filter toolbar */}
|
||||||
|
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search items..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{searchText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchText("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CategoryFilterDropdown
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
categories={categories ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Showing {filteredItems.length} of {items.length} items
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtered results */}
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
filteredItems.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500">No items match your search</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
weightGrams={item.weightGrams}
|
||||||
|
priceCents={item.priceCents}
|
||||||
|
categoryName={item.categoryName}
|
||||||
|
categoryIcon={item.categoryIcon}
|
||||||
|
imageFilename={item.imageFilename}
|
||||||
|
productUrl={item.productUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
Array.from(groupedItems.entries()).map(
|
||||||
|
([
|
||||||
|
categoryId,
|
||||||
|
{ items: categoryItems, categoryName, categoryIcon },
|
||||||
|
]) => {
|
||||||
|
const catTotals = categoryTotalsMap.get(categoryId);
|
||||||
|
return (
|
||||||
|
<div key={categoryId} className="mb-8">
|
||||||
|
<CategoryHeader
|
||||||
|
categoryId={categoryId}
|
||||||
|
name={categoryName}
|
||||||
|
icon={categoryIcon}
|
||||||
|
totalWeight={catTotals?.totalWeight ?? 0}
|
||||||
|
totalCost={catTotals?.totalCost ?? 0}
|
||||||
|
itemCount={catTotals?.itemCount ?? categoryItems.length}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{categoryItems.map((item) => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
name={item.name}
|
||||||
|
weightGrams={item.weightGrams}
|
||||||
|
priceCents={item.priceCents}
|
||||||
|
categoryName={categoryName}
|
||||||
|
categoryIcon={categoryIcon}
|
||||||
|
imageFilename={item.imageFilename}
|
||||||
|
productUrl={item.productUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { RankBadge } from "./CandidateListItem";
|
import { RankBadge } from "./CandidateListItem";
|
||||||
@@ -40,8 +38,7 @@ export function ComparisonTable({
|
|||||||
candidates,
|
candidates,
|
||||||
resolvedCandidateId,
|
resolvedCandidateId,
|
||||||
}: ComparisonTableProps) {
|
}: ComparisonTableProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
|
||||||
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||||
@@ -62,8 +59,7 @@ export function ComparisonTable({
|
|||||||
weightDeltas[c.id] = null;
|
weightDeltas[c.id] = null;
|
||||||
} else {
|
} else {
|
||||||
const delta = c.weightGrams - minWeight;
|
const delta = c.weightGrams - minWeight;
|
||||||
weightDeltas[c.id] =
|
weightDeltas[c.id] = delta === 0 ? null : `+${weight(delta)}`;
|
||||||
delta === 0 ? null : `+${formatWeight(delta, unit)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -88,8 +84,7 @@ export function ComparisonTable({
|
|||||||
priceDeltas[c.id] = null;
|
priceDeltas[c.id] = null;
|
||||||
} else {
|
} else {
|
||||||
const delta = c.priceCents - minPrice;
|
const delta = c.priceCents - minPrice;
|
||||||
priceDeltas[c.id] =
|
priceDeltas[c.id] = delta === 0 ? null : `+${price(delta)}`;
|
||||||
delta === 0 ? null : `+${formatPrice(delta, currency)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -99,7 +94,7 @@ export function ComparisonTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||||
}, [candidates, unit, currency]);
|
}, [candidates, weight, price]);
|
||||||
|
|
||||||
const ATTRIBUTE_ROWS: Array<{
|
const ATTRIBUTE_ROWS: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
@@ -155,7 +150,7 @@ export function ComparisonTable({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900">
|
||||||
{formatWeight(c.weightGrams, unit)}
|
{weight(c.weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
{!isBest && delta && (
|
{!isBest && delta && (
|
||||||
<div className="text-xs text-gray-400">{delta}</div>
|
<div className="text-xs text-gray-400">{delta}</div>
|
||||||
@@ -181,7 +176,7 @@ export function ComparisonTable({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900">
|
||||||
{formatPrice(c.priceCents, currency)}
|
{price(c.priceCents)}
|
||||||
</span>
|
</span>
|
||||||
{!isBest && delta && (
|
{!isBest && delta && (
|
||||||
<div className="text-xs text-gray-400">{delta}</div>
|
<div className="text-xs text-gray-400">{delta}</div>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { ClassificationBadge } from "./ClassificationBadge";
|
import { ClassificationBadge } from "./ClassificationBadge";
|
||||||
@@ -32,8 +30,7 @@ export function ItemCard({
|
|||||||
classification,
|
classification,
|
||||||
onClassificationCycle,
|
onClassificationCycle,
|
||||||
}: ItemCardProps) {
|
}: ItemCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
const openEditPanel = useUIStore((s) => s.openEditPanel);
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
|
||||||
@@ -131,12 +128,12 @@ export function ItemCard({
|
|||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{weightGrams != null && (
|
{weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(weightGrams, unit)}
|
{weight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{priceCents != null && (
|
{priceCents != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(priceCents, currency)}
|
{price(priceCents)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useItems } from "../hooks/useItems";
|
import { useItems } from "../hooks/useItems";
|
||||||
import { useSyncSetupItems } from "../hooks/useSetups";
|
import { useSyncSetupItems } from "../hooks/useSetups";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { SlideOutPanel } from "./SlideOutPanel";
|
import { SlideOutPanel } from "./SlideOutPanel";
|
||||||
|
|
||||||
@@ -22,8 +20,7 @@ export function ItemPicker({
|
|||||||
}: ItemPickerProps) {
|
}: ItemPickerProps) {
|
||||||
const { data: items } = useItems();
|
const { data: items } = useItems();
|
||||||
const syncItems = useSyncSetupItems(setupId);
|
const syncItems = useSyncSetupItems(setupId);
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Reset selected IDs when panel opens
|
// Reset selected IDs when panel opens
|
||||||
@@ -117,13 +114,11 @@ export function ItemPicker({
|
|||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 shrink-0">
|
<span className="text-xs text-gray-400 shrink-0">
|
||||||
{item.weightGrams != null &&
|
{item.weightGrams != null && weight(item.weightGrams)}
|
||||||
formatWeight(item.weightGrams, unit)}
|
|
||||||
{item.weightGrams != null &&
|
{item.weightGrams != null &&
|
||||||
item.priceCents != null &&
|
item.priceCents != null &&
|
||||||
" · "}
|
" · "}
|
||||||
{item.priceCents != null &&
|
{item.priceCents != null && price(item.priceCents)}
|
||||||
formatPrice(item.priceCents, currency)}
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|||||||
196
src/client/components/PlanningView.tsx
Normal file
196
src/client/components/PlanningView.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useCategories } from "../hooks/useCategories";
|
||||||
|
import { useThreads } from "../hooks/useThreads";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { CategoryFilterDropdown } from "./CategoryFilterDropdown";
|
||||||
|
import { CreateThreadModal } from "./CreateThreadModal";
|
||||||
|
import { ThreadCard } from "./ThreadCard";
|
||||||
|
|
||||||
|
export function PlanningView() {
|
||||||
|
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter threads by active tab and category
|
||||||
|
const filteredThreads = (threads ?? [])
|
||||||
|
.filter((t) => t.status === activeTab)
|
||||||
|
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
|
||||||
|
|
||||||
|
// Determine if we should show the educational empty state
|
||||||
|
const isEmptyNoFilters =
|
||||||
|
filteredThreads.length === 0 &&
|
||||||
|
activeTab === "active" &&
|
||||||
|
categoryFilter === null &&
|
||||||
|
(!threads || threads.length === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
Planning Threads
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateThreadModal}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
New Thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter row */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
{/* Pill tabs */}
|
||||||
|
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("active")}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
|
activeTab === "active"
|
||||||
|
? "bg-gray-700 text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("resolved")}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
||||||
|
activeTab === "resolved"
|
||||||
|
? "bg-gray-700 text-white"
|
||||||
|
: "text-gray-600 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Resolved
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
<CategoryFilterDropdown
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
categories={categories ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content: empty state or thread grid */}
|
||||||
|
{isEmptyNoFilters ? (
|
||||||
|
<div className="py-16">
|
||||||
|
<div className="max-w-lg mx-auto text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||||
|
Plan your next purchase
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6 text-left mb-10">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Create a thread</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Start a research thread for gear you're considering
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Add candidates</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Add products you're comparing with prices and weights
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Pick a winner</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Resolve the thread and the winner joins your collection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreateThreadModal}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Create your first thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredThreads.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500">No threads found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredThreads.map((thread) => (
|
||||||
|
<ThreadCard
|
||||||
|
key={thread.id}
|
||||||
|
id={thread.id}
|
||||||
|
name={thread.name}
|
||||||
|
candidateCount={thread.candidateCount}
|
||||||
|
minPriceCents={thread.minPriceCents}
|
||||||
|
maxPriceCents={thread.maxPriceCents}
|
||||||
|
createdAt={thread.createdAt}
|
||||||
|
status={thread.status}
|
||||||
|
categoryName={thread.categoryName}
|
||||||
|
categoryIcon={thread.categoryIcon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateThreadModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
|
|
||||||
interface SetupCardProps {
|
interface SetupCardProps {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -18,8 +16,7 @@ export function SetupCard({
|
|||||||
totalWeight,
|
totalWeight,
|
||||||
totalCost,
|
totalCost,
|
||||||
}: SetupCardProps) {
|
}: SetupCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to="/setups/$setupId"
|
to="/setups/$setupId"
|
||||||
@@ -34,10 +31,10 @@ export function SetupCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(totalWeight, unit)}
|
{weight(totalWeight)}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
|
||||||
{formatPrice(totalCost, currency)}
|
{price(totalCost)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
112
src/client/components/SetupsView.tsx
Normal file
112
src/client/components/SetupsView.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useCreateSetup, useSetups } from "../hooks/useSetups";
|
||||||
|
import { SetupCard } from "./SetupCard";
|
||||||
|
|
||||||
|
export function SetupsView() {
|
||||||
|
const [newSetupName, setNewSetupName] = useState("");
|
||||||
|
const { data: setups, isLoading } = useSetups();
|
||||||
|
const createSetup = useCreateSetup();
|
||||||
|
|
||||||
|
function handleCreateSetup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newSetupName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Create setup form */}
|
||||||
|
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSetupName}
|
||||||
|
onChange={(e) => setNewSetupName(e.target.value)}
|
||||||
|
placeholder="New setup name..."
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newSetupName.trim() || createSetup.isPending}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{createSetup.isPending ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-24 bg-gray-200 rounded-xl animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && (!setups || setups.length === 0) && (
|
||||||
|
<div className="py-16">
|
||||||
|
<div className="max-w-lg mx-auto text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
||||||
|
Build your perfect loadout
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6 text-left mb-10">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Create a setup</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Name your loadout for a specific trip or activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Add items</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Pick gear from your collection to include in the setup
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Track weight</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
See weight breakdown and optimize your pack
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Setup grid */}
|
||||||
|
{!isLoading && setups && setups.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{setups.map((setup) => (
|
||||||
|
<SetupCard
|
||||||
|
key={setup.id}
|
||||||
|
id={setup.id}
|
||||||
|
name={setup.name}
|
||||||
|
itemCount={setup.itemCount}
|
||||||
|
totalWeight={setup.totalWeight}
|
||||||
|
totalCost={setup.totalCost}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { formatPrice } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
interface ThreadCardProps {
|
interface ThreadCardProps {
|
||||||
@@ -20,16 +19,6 @@ function formatDate(iso: string): string {
|
|||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPriceRange(
|
|
||||||
min: number | null,
|
|
||||||
max: number | null,
|
|
||||||
currency: Parameters<typeof formatPrice>[1],
|
|
||||||
): string | null {
|
|
||||||
if (min == null && max == null) return null;
|
|
||||||
if (min === max) return formatPrice(min, currency);
|
|
||||||
return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThreadCard({
|
export function ThreadCard({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -42,10 +31,19 @@ export function ThreadCard({
|
|||||||
categoryIcon,
|
categoryIcon,
|
||||||
}: ThreadCardProps) {
|
}: ThreadCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const currency = useCurrency();
|
const { price } = useFormatters();
|
||||||
|
|
||||||
|
function formatPriceRange(
|
||||||
|
min: number | null,
|
||||||
|
max: number | null,
|
||||||
|
): string | null {
|
||||||
|
if (min == null && max == null) return null;
|
||||||
|
if (min === max) return price(min);
|
||||||
|
return `${price(min)} - ${price(max)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const isResolved = status === "resolved";
|
const isResolved = status === "resolved";
|
||||||
const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
|
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useAuth, useLogout } from "../hooks/useAuth";
|
import { useAuth, useLogout } from "../hooks/useAuth";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useUpdateSetting } from "../hooks/useSettings";
|
import { useUpdateSetting } from "../hooks/useSettings";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
import type { WeightUnit } from "../lib/formatters";
|
||||||
import { formatPrice, formatWeight, type WeightUnit } from "../lib/formatters";
|
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
||||||
@@ -23,7 +23,7 @@ export function TotalsBar({
|
|||||||
const { data: auth } = useAuth();
|
const { data: auth } = useAuth();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const isAuthenticated = !!auth?.user;
|
const isAuthenticated = !!auth?.user;
|
||||||
const unit = useWeightUnit();
|
const { weight, price, unit } = useFormatters();
|
||||||
const updateSetting = useUpdateSetting();
|
const updateSetting = useUpdateSetting();
|
||||||
|
|
||||||
// When no stats provided, use global totals (backward compatible)
|
// When no stats provided, use global totals (backward compatible)
|
||||||
@@ -34,14 +34,14 @@ export function TotalsBar({
|
|||||||
{ label: "items", value: String(data.global.itemCount) },
|
{ label: "items", value: String(data.global.itemCount) },
|
||||||
{
|
{
|
||||||
label: "total",
|
label: "total",
|
||||||
value: formatWeight(data.global.totalWeight, unit),
|
value: weight(data.global.totalWeight),
|
||||||
},
|
},
|
||||||
{ label: "spent", value: formatPrice(data.global.totalCost) },
|
{ label: "spent", value: price(data.global.totalCost) },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ label: "items", value: "0" },
|
{ label: "items", value: "0" },
|
||||||
{ label: "total", value: formatWeight(null, unit) },
|
{ label: "total", value: weight(null) },
|
||||||
{ label: "spent", value: formatPrice(null) },
|
{ label: "spent", value: price(null) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const titleContent = (
|
const titleContent = (
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
import type { SetupItemWithCategory } from "../hooks/useSetups";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
import { formatWeight, type WeightUnit } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ function LegendRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
|
||||||
const unit = useWeightUnit();
|
const { unit } = useFormatters();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
const [viewMode, setViewMode] = useState<ViewMode>("category");
|
||||||
|
|
||||||
const baseWeight = items.reduce(
|
const baseWeight = items.reduce(
|
||||||
|
|||||||
14
src/client/hooks/useFormatters.ts
Normal file
14
src/client/hooks/useFormatters.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
import { useCurrency } from "./useCurrency";
|
||||||
|
import { useWeightUnit } from "./useWeightUnit";
|
||||||
|
|
||||||
|
export function useFormatters() {
|
||||||
|
const unit = useWeightUnit();
|
||||||
|
const currency = useCurrency();
|
||||||
|
return {
|
||||||
|
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||||
|
price: (cents: number | null) => formatPrice(cents, currency),
|
||||||
|
unit,
|
||||||
|
currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
createRootRoute,
|
createRootRoute,
|
||||||
|
type ErrorComponentProps,
|
||||||
Outlet,
|
Outlet,
|
||||||
useMatchRoute,
|
useMatchRoute,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
|
useRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
@@ -21,8 +23,53 @@ import { useUIStore } from "../stores/uiStore";
|
|||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
|
errorComponent: RootErrorBoundary,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="max-w-md mx-auto text-center px-4">
|
||||||
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unexpected error occurred"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
router.invalidate();
|
||||||
|
}}
|
||||||
|
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: auth } = useAuth();
|
const { data: auth } = useAuth();
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useRef } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CategoryFilterDropdown } from "../../components/CategoryFilterDropdown";
|
import { CollectionView } from "../../components/CollectionView";
|
||||||
import { CategoryHeader } from "../../components/CategoryHeader";
|
import { PlanningView } from "../../components/PlanningView";
|
||||||
import { CreateThreadModal } from "../../components/CreateThreadModal";
|
import { SetupsView } from "../../components/SetupsView";
|
||||||
import { ItemCard } from "../../components/ItemCard";
|
|
||||||
import { SetupCard } from "../../components/SetupCard";
|
|
||||||
import { ThreadCard } from "../../components/ThreadCard";
|
|
||||||
import { useCategories } from "../../hooks/useCategories";
|
|
||||||
import { useCurrency } from "../../hooks/useCurrency";
|
|
||||||
import { useItems } from "../../hooks/useItems";
|
|
||||||
import { useCreateSetup, useSetups } from "../../hooks/useSetups";
|
|
||||||
import { useThreads } from "../../hooks/useThreads";
|
|
||||||
import { useTotals } from "../../hooks/useTotals";
|
|
||||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
tab: z.enum(["gear", "planning", "setups"]).catch("gear"),
|
||||||
@@ -68,566 +55,3 @@ function CollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollectionView() {
|
|
||||||
const { data: items, isLoading: itemsLoading } = useItems();
|
|
||||||
const { data: totals } = useTotals();
|
|
||||||
const { data: categories } = useCategories();
|
|
||||||
const unit = useWeightUnit();
|
|
||||||
const currency = useCurrency();
|
|
||||||
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
if (!items) return [];
|
|
||||||
return items.filter((item) => {
|
|
||||||
const matchesSearch =
|
|
||||||
searchText === "" ||
|
|
||||||
item.name.toLowerCase().includes(searchText.toLowerCase());
|
|
||||||
const matchesCategory =
|
|
||||||
categoryFilter === null || item.categoryId === categoryFilter;
|
|
||||||
return matchesSearch && matchesCategory;
|
|
||||||
});
|
|
||||||
}, [items, searchText, categoryFilter]);
|
|
||||||
|
|
||||||
const hasActiveFilters = searchText !== "" || categoryFilter !== null;
|
|
||||||
|
|
||||||
if (itemsLoading) {
|
|
||||||
return (
|
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-48" />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-16 text-center">
|
|
||||||
<div className="max-w-md mx-auto">
|
|
||||||
<div className="mb-4">
|
|
||||||
<LucideIcon
|
|
||||||
name="backpack"
|
|
||||||
size={48}
|
|
||||||
className="text-gray-400 mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
Your collection is empty
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
|
||||||
Start cataloging your gear by adding your first item. Track weight,
|
|
||||||
price, and organize by category.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openAddPanel}
|
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Add your first item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build category totals lookup
|
|
||||||
const categoryTotalsMap = new Map<
|
|
||||||
number,
|
|
||||||
{ totalWeight: number; totalCost: number; itemCount: number }
|
|
||||||
>();
|
|
||||||
if (totals?.categories) {
|
|
||||||
for (const ct of totals.categories) {
|
|
||||||
categoryTotalsMap.set(ct.categoryId, {
|
|
||||||
totalWeight: ct.totalWeight,
|
|
||||||
totalCost: ct.totalCost,
|
|
||||||
itemCount: ct.itemCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group filtered items by categoryId (used when no active filters)
|
|
||||||
const groupedItems = new Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
items: typeof filteredItems;
|
|
||||||
categoryName: string;
|
|
||||||
categoryIcon: string;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const item of filteredItems) {
|
|
||||||
const group = groupedItems.get(item.categoryId);
|
|
||||||
if (group) {
|
|
||||||
group.items.push(item);
|
|
||||||
} else {
|
|
||||||
groupedItems.set(item.categoryId, {
|
|
||||||
items: [item],
|
|
||||||
categoryName: item.categoryName,
|
|
||||||
categoryIcon: item.categoryIcon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Collection stats card */}
|
|
||||||
{totals?.global && (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<LucideIcon name="layers" size={14} className="text-gray-400" />
|
|
||||||
<span className="text-xs text-gray-500">Items</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
{totals.global.itemCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<LucideIcon name="weight" size={14} className="text-gray-400" />
|
|
||||||
<span className="text-xs text-gray-500">Total Weight</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
{formatWeight(totals.global.totalWeight, unit)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<LucideIcon
|
|
||||||
name="credit-card"
|
|
||||||
size={14}
|
|
||||||
className="text-gray-400"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-500">Total Spent</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
|
||||||
{formatPrice(totals.global.totalCost, currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search/filter toolbar */}
|
|
||||||
<div className="sticky top-0 z-10 bg-gray-50/95 backdrop-blur-sm border-b border-gray-100 -mx-4 px-4 py-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 mb-6">
|
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search items..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
{searchText && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchText("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CategoryFilterDropdown
|
|
||||||
value={categoryFilter}
|
|
||||||
onChange={setCategoryFilter}
|
|
||||||
categories={categories ?? []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
Showing {filteredItems.length} of {items.length} items
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtered results */}
|
|
||||||
{hasActiveFilters ? (
|
|
||||||
filteredItems.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<p className="text-sm text-gray-500">No items match your search</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filteredItems.map((item) => (
|
|
||||||
<ItemCard
|
|
||||||
key={item.id}
|
|
||||||
id={item.id}
|
|
||||||
name={item.name}
|
|
||||||
weightGrams={item.weightGrams}
|
|
||||||
priceCents={item.priceCents}
|
|
||||||
categoryName={item.categoryName}
|
|
||||||
categoryIcon={item.categoryIcon}
|
|
||||||
imageFilename={item.imageFilename}
|
|
||||||
productUrl={item.productUrl}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
Array.from(groupedItems.entries()).map(
|
|
||||||
([
|
|
||||||
categoryId,
|
|
||||||
{ items: categoryItems, categoryName, categoryIcon },
|
|
||||||
]) => {
|
|
||||||
const catTotals = categoryTotalsMap.get(categoryId);
|
|
||||||
return (
|
|
||||||
<div key={categoryId} className="mb-8">
|
|
||||||
<CategoryHeader
|
|
||||||
categoryId={categoryId}
|
|
||||||
name={categoryName}
|
|
||||||
icon={categoryIcon}
|
|
||||||
totalWeight={catTotals?.totalWeight ?? 0}
|
|
||||||
totalCost={catTotals?.totalCost ?? 0}
|
|
||||||
itemCount={catTotals?.itemCount ?? categoryItems.length}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{categoryItems.map((item) => (
|
|
||||||
<ItemCard
|
|
||||||
key={item.id}
|
|
||||||
id={item.id}
|
|
||||||
name={item.name}
|
|
||||||
weightGrams={item.weightGrams}
|
|
||||||
priceCents={item.priceCents}
|
|
||||||
categoryName={categoryName}
|
|
||||||
categoryIcon={categoryIcon}
|
|
||||||
imageFilename={item.imageFilename}
|
|
||||||
productUrl={item.productUrl}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlanningView() {
|
|
||||||
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
|
|
||||||
const { data: categories } = useCategories();
|
|
||||||
const { data: threads, isLoading } = useThreads(activeTab === "resolved");
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter threads by active tab and category
|
|
||||||
const filteredThreads = (threads ?? [])
|
|
||||||
.filter((t) => t.status === activeTab)
|
|
||||||
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
|
|
||||||
|
|
||||||
// Determine if we should show the educational empty state
|
|
||||||
const isEmptyNoFilters =
|
|
||||||
filteredThreads.length === 0 &&
|
|
||||||
activeTab === "active" &&
|
|
||||||
categoryFilter === null &&
|
|
||||||
(!threads || threads.length === 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
|
||||||
Planning Threads
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCreateThreadModal}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
New Thread
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter row */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
{/* Pill tabs */}
|
|
||||||
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("active")}
|
|
||||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
|
||||||
activeTab === "active"
|
|
||||||
? "bg-gray-700 text-white"
|
|
||||||
: "text-gray-600 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Active
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("resolved")}
|
|
||||||
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
|
|
||||||
activeTab === "resolved"
|
|
||||||
? "bg-gray-700 text-white"
|
|
||||||
: "text-gray-600 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Resolved
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category filter */}
|
|
||||||
<CategoryFilterDropdown
|
|
||||||
value={categoryFilter}
|
|
||||||
onChange={setCategoryFilter}
|
|
||||||
categories={categories ?? []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content: empty state or thread grid */}
|
|
||||||
{isEmptyNoFilters ? (
|
|
||||||
<div className="py-16">
|
|
||||||
<div className="max-w-lg mx-auto text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
|
||||||
Plan your next purchase
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6 text-left mb-10">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Create a thread</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Start a research thread for gear you're considering
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Add candidates</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Add products you're comparing with prices and weights
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Pick a winner</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Resolve the thread and the winner joins your collection
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCreateThreadModal}
|
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Create your first thread
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : filteredThreads.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<p className="text-sm text-gray-500">No threads found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filteredThreads.map((thread) => (
|
|
||||||
<ThreadCard
|
|
||||||
key={thread.id}
|
|
||||||
id={thread.id}
|
|
||||||
name={thread.name}
|
|
||||||
candidateCount={thread.candidateCount}
|
|
||||||
minPriceCents={thread.minPriceCents}
|
|
||||||
maxPriceCents={thread.maxPriceCents}
|
|
||||||
createdAt={thread.createdAt}
|
|
||||||
status={thread.status}
|
|
||||||
categoryName={thread.categoryName}
|
|
||||||
categoryIcon={thread.categoryIcon}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateThreadModal />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SetupsView() {
|
|
||||||
const [newSetupName, setNewSetupName] = useState("");
|
|
||||||
const { data: setups, isLoading } = useSetups();
|
|
||||||
const createSetup = useCreateSetup();
|
|
||||||
|
|
||||||
function handleCreateSetup(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const name = newSetupName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
createSetup.mutate({ name }, { onSuccess: () => setNewSetupName("") });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Create setup form */}
|
|
||||||
<form onSubmit={handleCreateSetup} className="flex gap-2 mb-6">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newSetupName}
|
|
||||||
onChange={(e) => setNewSetupName(e.target.value)}
|
|
||||||
placeholder="New setup name..."
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!newSetupName.trim() || createSetup.isPending}
|
|
||||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{createSetup.isPending ? "Creating..." : "Create"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Loading skeleton */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{[1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-24 bg-gray-200 rounded-xl animate-pulse"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!isLoading && (!setups || setups.length === 0) && (
|
|
||||||
<div className="py-16">
|
|
||||||
<div className="max-w-lg mx-auto text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-8">
|
|
||||||
Build your perfect loadout
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-6 text-left mb-10">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Create a setup</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Name your loadout for a specific trip or activity
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Add items</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Pick gear from your collection to include in the setup
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 text-gray-700 font-bold text-sm shrink-0">
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Track weight</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
See weight breakdown and optimize your pack
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Setup grid */}
|
|
||||||
{!isLoading && setups && setups.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{setups.map((setup) => (
|
|
||||||
<SetupCard
|
|
||||||
key={setup.id}
|
|
||||||
id={setup.id}
|
|
||||||
name={setup.name}
|
|
||||||
itemCount={setup.itemCount}
|
|
||||||
totalWeight={setup.totalWeight}
|
|
||||||
totalCost={setup.totalCost}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { DashboardCard } from "../components/DashboardCard";
|
import { DashboardCard } from "../components/DashboardCard";
|
||||||
import { useCurrency } from "../hooks/useCurrency";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
import { useSetups } from "../hooks/useSetups";
|
import { useSetups } from "../hooks/useSetups";
|
||||||
import { useThreads } from "../hooks/useThreads";
|
import { useThreads } from "../hooks/useThreads";
|
||||||
import { useTotals } from "../hooks/useTotals";
|
import { useTotals } from "../hooks/useTotals";
|
||||||
import { useWeightUnit } from "../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
@@ -15,8 +13,7 @@ function DashboardPage() {
|
|||||||
const { data: totals } = useTotals();
|
const { data: totals } = useTotals();
|
||||||
const { data: threads } = useThreads(false);
|
const { data: threads } = useThreads(false);
|
||||||
const { data: setups } = useSetups();
|
const { data: setups } = useSetups();
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
|
|
||||||
const global = totals?.global;
|
const global = totals?.global;
|
||||||
const activeThreadCount = threads?.length ?? 0;
|
const activeThreadCount = threads?.length ?? 0;
|
||||||
@@ -33,11 +30,11 @@ function DashboardPage() {
|
|||||||
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
{ label: "Items", value: String(global?.itemCount ?? 0) },
|
||||||
{
|
{
|
||||||
label: "Weight",
|
label: "Weight",
|
||||||
value: formatWeight(global?.totalWeight ?? null, unit),
|
value: weight(global?.totalWeight ?? null),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Cost",
|
label: "Cost",
|
||||||
value: formatPrice(global?.totalCost ?? null, currency),
|
value: price(global?.totalCost ?? null),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
emptyText="Get started"
|
emptyText="Get started"
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import { CategoryHeader } from "../../components/CategoryHeader";
|
|||||||
import { ItemCard } from "../../components/ItemCard";
|
import { ItemCard } from "../../components/ItemCard";
|
||||||
import { ItemPicker } from "../../components/ItemPicker";
|
import { ItemPicker } from "../../components/ItemPicker";
|
||||||
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
import { WeightSummaryCard } from "../../components/WeightSummaryCard";
|
||||||
import { useCurrency } from "../../hooks/useCurrency";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import {
|
import {
|
||||||
useDeleteSetup,
|
useDeleteSetup,
|
||||||
useRemoveSetupItem,
|
useRemoveSetupItem,
|
||||||
useSetup,
|
useSetup,
|
||||||
useUpdateItemClassification,
|
useUpdateItemClassification,
|
||||||
} from "../../hooks/useSetups";
|
} from "../../hooks/useSetups";
|
||||||
import { useWeightUnit } from "../../hooks/useWeightUnit";
|
|
||||||
import { formatPrice, formatWeight } from "../../lib/formatters";
|
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
|
|
||||||
export const Route = createFileRoute("/setups/$setupId")({
|
export const Route = createFileRoute("/setups/$setupId")({
|
||||||
@@ -21,8 +19,7 @@ export const Route = createFileRoute("/setups/$setupId")({
|
|||||||
|
|
||||||
function SetupDetailPage() {
|
function SetupDetailPage() {
|
||||||
const { setupId } = Route.useParams();
|
const { setupId } = Route.useParams();
|
||||||
const unit = useWeightUnit();
|
const { weight, price } = useFormatters();
|
||||||
const currency = useCurrency();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const numericId = Number(setupId);
|
const numericId = Number(setupId);
|
||||||
const { data: setup, isLoading } = useSetup(numericId);
|
const { data: setup, isLoading } = useSetup(numericId);
|
||||||
@@ -127,13 +124,13 @@ function SetupDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-gray-700">
|
<span className="font-medium text-gray-700">
|
||||||
{formatWeight(totalWeight, unit)}
|
{weight(totalWeight)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
total
|
total
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-gray-700">
|
<span className="font-medium text-gray-700">
|
||||||
{formatPrice(totalCost, currency)}
|
{price(totalCost)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
cost
|
cost
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { db as prodDb } from "../db/index.ts";
|
||||||
import { seedDefaults } from "../db/seed.ts";
|
import { seedDefaults } from "../db/seed.ts";
|
||||||
import { mcpRoutes } from "./mcp/index.ts";
|
import { mcpRoutes } from "./mcp/index.ts";
|
||||||
import { requireAuth } from "./middleware/auth.ts";
|
import { requireAuth } from "./middleware/auth.ts";
|
||||||
@@ -17,12 +18,28 @@ seedDefaults();
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Centralized error handler
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(`[${c.req.method}] ${c.req.path}:`, err);
|
||||||
|
const message =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "Internal server error"
|
||||||
|
: err.message || "Internal server error";
|
||||||
|
return c.json({ error: message }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/api/health", (c) => {
|
app.get("/api/health", (c) => {
|
||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth middleware for write operations (POST/PUT/DELETE) on non-auth routes
|
// Inject production database into request context
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
c.set("db", prodDb);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
// Skip auth routes — they handle their own auth
|
// Skip auth routes — they handle their own auth
|
||||||
if (c.req.path.startsWith("/api/auth")) return next();
|
if (c.req.path.startsWith("/api/auth")) return next();
|
||||||
|
|||||||
9
src/server/lib/params.ts
Normal file
9
src/server/lib/params.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Parse a route parameter as a positive integer ID.
|
||||||
|
* Returns the number if valid, or null if the string is not a positive integer.
|
||||||
|
*/
|
||||||
|
export function parseId(raw: string): number | null {
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
53
src/server/middleware/rateLimit.ts
Normal file
53
src/server/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Context, Next } from "hono";
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
function getClientIp(c: Context): string {
|
||||||
|
return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (now >= entry.resetAt) {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rateLimit(c: Context, next: Next) {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const ip = getClientIp(c);
|
||||||
|
const key = `${ip}:${c.req.path}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const entry = store.get(key);
|
||||||
|
|
||||||
|
if (!entry || now >= entry.resetAt) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= MAX_ATTEMPTS) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
c.header("Retry-After", String(retryAfter));
|
||||||
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — only for testing */
|
||||||
|
export function _resetForTesting() {
|
||||||
|
store.clear();
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import { Hono } from "hono";
|
|||||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { users } from "../../db/schema.ts";
|
import { users } from "../../db/schema.ts";
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
import { requireAuth } from "../middleware/auth.ts";
|
import { requireAuth } from "../middleware/auth.ts";
|
||||||
|
import { rateLimit } from "../middleware/rateLimit.ts";
|
||||||
import {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
@@ -59,7 +61,7 @@ app.get("/me", (c) => {
|
|||||||
return c.json({ user: null, setupRequired });
|
return c.json({ user: null, setupRequired });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/setup", zValidator("json", setupSchema), async (c) => {
|
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
|
|
||||||
if (getUserCount(db) > 0) {
|
if (getUserCount(db) > 0) {
|
||||||
@@ -80,7 +82,7 @@ app.post("/setup", zValidator("json", setupSchema), async (c) => {
|
|||||||
return c.json({ username: user.username }, 201);
|
return c.json({ username: user.username }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/login", zValidator("json", loginSchema), async (c) => {
|
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const { username, password } = c.req.valid("json");
|
const { username, password } = c.req.valid("json");
|
||||||
|
|
||||||
@@ -186,7 +188,8 @@ app.post(
|
|||||||
|
|
||||||
app.delete("/keys/:id", requireAuth, (c) => {
|
app.delete("/keys/:id", requireAuth, (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid key ID" }, 400);
|
||||||
deleteApiKey(db, id);
|
deleteApiKey(db, id);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createCategorySchema,
|
createCategorySchema,
|
||||||
updateCategorySchema,
|
updateCategorySchema,
|
||||||
} from "../../shared/schemas.ts";
|
} from "../../shared/schemas.ts";
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
@@ -33,7 +34,8 @@ app.put(
|
|||||||
zValidator("json", updateCategorySchema.omit({ id: true })),
|
zValidator("json", updateCategorySchema.omit({ id: true })),
|
||||||
(c) => {
|
(c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const cat = updateCategory(db, id, data);
|
const cat = updateCategory(db, id, data);
|
||||||
if (!cat) return c.json({ error: "Category not found" }, 404);
|
if (!cat) return c.json({ error: "Category not found" }, 404);
|
||||||
@@ -43,7 +45,8 @@ app.put(
|
|||||||
|
|
||||||
app.delete("/:id", (c) => {
|
app.delete("/:id", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid category ID" }, 400);
|
||||||
const result = deleteCategory(db, id);
|
const result = deleteCategory(db, id);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
@@ -23,7 +24,8 @@ app.get("/", (c) => {
|
|||||||
|
|
||||||
app.get("/:id", (c) => {
|
app.get("/:id", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||||
const item = getItemById(db, id);
|
const item = getItemById(db, id);
|
||||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||||
return c.json(item);
|
return c.json(item);
|
||||||
@@ -41,7 +43,8 @@ app.put(
|
|||||||
zValidator("json", updateItemSchema.omit({ id: true })),
|
zValidator("json", updateItemSchema.omit({ id: true })),
|
||||||
(c) => {
|
(c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const item = updateItem(db, id, data);
|
const item = updateItem(db, id, data);
|
||||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||||
@@ -51,7 +54,8 @@ app.put(
|
|||||||
|
|
||||||
app.delete("/:id", async (c) => {
|
app.delete("/:id", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||||
const deleted = deleteItem(db, id);
|
const deleted = deleteItem(db, id);
|
||||||
if (!deleted) return c.json({ error: "Item not found" }, 404);
|
if (!deleted) return c.json({ error: "Item not found" }, 404);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db as prodDb } from "../../db/index.ts";
|
|
||||||
import { settings } from "../../db/schema.ts";
|
import { settings } from "../../db/schema.ts";
|
||||||
|
|
||||||
type Env = { Variables: { db?: any } };
|
type Env = { Variables: { db?: any } };
|
||||||
@@ -8,7 +7,7 @@ type Env = { Variables: { db?: any } };
|
|||||||
const app = new Hono<Env>();
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
app.get("/:key", (c) => {
|
app.get("/:key", (c) => {
|
||||||
const database = c.get("db") ?? prodDb;
|
const database = c.get("db");
|
||||||
const key = c.req.param("key");
|
const key = c.req.param("key");
|
||||||
const row = database
|
const row = database
|
||||||
.select()
|
.select()
|
||||||
@@ -20,7 +19,7 @@ app.get("/:key", (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.put("/:key", async (c) => {
|
app.put("/:key", async (c) => {
|
||||||
const database = c.get("db") ?? prodDb;
|
const database = c.get("db");
|
||||||
const key = c.req.param("key");
|
const key = c.req.param("key");
|
||||||
const body = await c.req.json<{ value: string }>();
|
const body = await c.req.json<{ value: string }>();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
updateClassificationSchema,
|
updateClassificationSchema,
|
||||||
updateSetupSchema,
|
updateSetupSchema,
|
||||||
} from "../../shared/schemas.ts";
|
} from "../../shared/schemas.ts";
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
import {
|
import {
|
||||||
createSetup,
|
createSetup,
|
||||||
deleteSetup,
|
deleteSetup,
|
||||||
@@ -38,7 +39,8 @@ app.post("/", zValidator("json", createSetupSchema), (c) => {
|
|||||||
|
|
||||||
app.get("/:id", (c) => {
|
app.get("/:id", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||||
const setup = getSetupWithItems(db, id);
|
const setup = getSetupWithItems(db, id);
|
||||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||||
return c.json(setup);
|
return c.json(setup);
|
||||||
@@ -46,7 +48,8 @@ app.get("/:id", (c) => {
|
|||||||
|
|
||||||
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
|
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const setup = updateSetup(db, id, data);
|
const setup = updateSetup(db, id, data);
|
||||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||||
@@ -55,7 +58,8 @@ app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
|
|||||||
|
|
||||||
app.delete("/:id", (c) => {
|
app.delete("/:id", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||||
const deleted = deleteSetup(db, id);
|
const deleted = deleteSetup(db, id);
|
||||||
if (!deleted) return c.json({ error: "Setup not found" }, 404);
|
if (!deleted) return c.json({ error: "Setup not found" }, 404);
|
||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
@@ -65,7 +69,8 @@ app.delete("/:id", (c) => {
|
|||||||
|
|
||||||
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
|
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
|
||||||
const { itemIds } = c.req.valid("json");
|
const { itemIds } = c.req.valid("json");
|
||||||
|
|
||||||
const setup = getSetupWithItems(db, id);
|
const setup = getSetupWithItems(db, id);
|
||||||
@@ -80,8 +85,9 @@ app.patch(
|
|||||||
zValidator("json", updateClassificationSchema),
|
zValidator("json", updateClassificationSchema),
|
||||||
(c) => {
|
(c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const setupId = Number(c.req.param("id"));
|
const setupId = parseId(c.req.param("id"));
|
||||||
const itemId = Number(c.req.param("itemId"));
|
const itemId = parseId(c.req.param("itemId"));
|
||||||
|
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||||
const { classification } = c.req.valid("json");
|
const { classification } = c.req.valid("json");
|
||||||
updateItemClassification(db, setupId, itemId, classification);
|
updateItemClassification(db, setupId, itemId, classification);
|
||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
@@ -90,8 +96,9 @@ app.patch(
|
|||||||
|
|
||||||
app.delete("/:id/items/:itemId", (c) => {
|
app.delete("/:id/items/:itemId", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const setupId = Number(c.req.param("id"));
|
const setupId = parseId(c.req.param("id"));
|
||||||
const itemId = Number(c.req.param("itemId"));
|
const itemId = parseId(c.req.param("itemId"));
|
||||||
|
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
|
||||||
removeSetupItem(db, setupId, itemId);
|
removeSetupItem(db, setupId, itemId);
|
||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
updateCandidateSchema,
|
updateCandidateSchema,
|
||||||
updateThreadSchema,
|
updateThreadSchema,
|
||||||
} from "../../shared/schemas.ts";
|
} from "../../shared/schemas.ts";
|
||||||
|
import { parseId } from "../lib/params.ts";
|
||||||
import {
|
import {
|
||||||
createCandidate,
|
createCandidate,
|
||||||
createThread,
|
createThread,
|
||||||
@@ -45,7 +46,8 @@ app.post("/", zValidator("json", createThreadSchema), (c) => {
|
|||||||
|
|
||||||
app.get("/:id", (c) => {
|
app.get("/:id", (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
|
||||||
const thread = getThreadWithCandidates(db, id);
|
const thread = getThreadWithCandidates(db, id);
|
||||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||||
return c.json(thread);
|
return c.json(thread);
|
||||||
@@ -53,7 +55,8 @@ app.get("/:id", (c) => {
|
|||||||
|
|
||||||
app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
|
app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const thread = updateThread(db, id, data);
|
const thread = updateThread(db, id, data);
|
||||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||||
@@ -62,7 +65,8 @@ app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
|
|||||||
|
|
||||||
app.delete("/:id", async (c) => {
|
app.delete("/:id", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const id = Number(c.req.param("id"));
|
const id = parseId(c.req.param("id"));
|
||||||
|
if (!id) return c.json({ error: "Invalid thread ID" }, 400);
|
||||||
const deleted = deleteThread(db, id);
|
const deleted = deleteThread(db, id);
|
||||||
if (!deleted) return c.json({ error: "Thread not found" }, 404);
|
if (!deleted) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
@@ -82,7 +86,8 @@ app.delete("/:id", async (c) => {
|
|||||||
|
|
||||||
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
|
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const threadId = Number(c.req.param("id"));
|
const threadId = parseId(c.req.param("id"));
|
||||||
|
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
|
||||||
|
|
||||||
// Verify thread exists
|
// Verify thread exists
|
||||||
const thread = getThreadWithCandidates(db, threadId);
|
const thread = getThreadWithCandidates(db, threadId);
|
||||||
@@ -98,7 +103,8 @@ app.put(
|
|||||||
zValidator("json", updateCandidateSchema),
|
zValidator("json", updateCandidateSchema),
|
||||||
(c) => {
|
(c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const candidateId = Number(c.req.param("candidateId"));
|
const candidateId = parseId(c.req.param("candidateId"));
|
||||||
|
if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400);
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const candidate = updateCandidate(db, candidateId, data);
|
const candidate = updateCandidate(db, candidateId, data);
|
||||||
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
|
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
|
||||||
@@ -108,7 +114,8 @@ app.put(
|
|||||||
|
|
||||||
app.delete("/:threadId/candidates/:candidateId", async (c) => {
|
app.delete("/:threadId/candidates/:candidateId", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const candidateId = Number(c.req.param("candidateId"));
|
const candidateId = parseId(c.req.param("candidateId"));
|
||||||
|
if (!candidateId) return c.json({ error: "Invalid candidate ID" }, 400);
|
||||||
const deleted = deleteCandidate(db, candidateId);
|
const deleted = deleteCandidate(db, candidateId);
|
||||||
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
|
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
|
||||||
|
|
||||||
@@ -131,7 +138,8 @@ app.patch(
|
|||||||
zValidator("json", reorderCandidatesSchema),
|
zValidator("json", reorderCandidatesSchema),
|
||||||
(c) => {
|
(c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const threadId = Number(c.req.param("id"));
|
const threadId = parseId(c.req.param("id"));
|
||||||
|
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
|
||||||
const { orderedIds } = c.req.valid("json");
|
const { orderedIds } = c.req.valid("json");
|
||||||
const result = reorderCandidates(db, threadId, orderedIds);
|
const result = reorderCandidates(db, threadId, orderedIds);
|
||||||
if (!result.success) return c.json({ error: result.error }, 400);
|
if (!result.success) return c.json({ error: result.error }, 400);
|
||||||
@@ -143,7 +151,8 @@ app.patch(
|
|||||||
|
|
||||||
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
|
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const threadId = Number(c.req.param("id"));
|
const threadId = parseId(c.req.param("id"));
|
||||||
|
if (!threadId) return c.json({ error: "Invalid thread ID" }, 400);
|
||||||
const { candidateId } = c.req.valid("json");
|
const { candidateId } = c.req.valid("json");
|
||||||
|
|
||||||
const result = resolveThread(db, threadId, candidateId);
|
const result = resolveThread(db, threadId, candidateId);
|
||||||
|
|||||||
@@ -1,124 +1,17 @@
|
|||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import * as schema from "../../src/db/schema.ts";
|
import * as schema from "../../src/db/schema.ts";
|
||||||
|
|
||||||
export function createTestDb() {
|
export function createTestDb() {
|
||||||
const sqlite = new Database(":memory:");
|
const sqlite = new Database(":memory:");
|
||||||
sqlite.run("PRAGMA foreign_keys = ON");
|
sqlite.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
// Create tables matching the Drizzle schema
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE categories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
icon TEXT NOT NULL DEFAULT 'package',
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
weight_grams REAL,
|
|
||||||
price_cents INTEGER,
|
|
||||||
category_id INTEGER NOT NULL REFERENCES categories(id),
|
|
||||||
notes TEXT,
|
|
||||||
product_url TEXT,
|
|
||||||
image_filename TEXT,
|
|
||||||
image_source_url TEXT,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE threads (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
resolved_candidate_id INTEGER,
|
|
||||||
category_id INTEGER NOT NULL REFERENCES categories(id),
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE thread_candidates (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
weight_grams REAL,
|
|
||||||
price_cents INTEGER,
|
|
||||||
category_id INTEGER NOT NULL REFERENCES categories(id),
|
|
||||||
notes TEXT,
|
|
||||||
product_url TEXT,
|
|
||||||
image_filename TEXT,
|
|
||||||
image_source_url TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'researching',
|
|
||||||
pros TEXT,
|
|
||||||
cons TEXT,
|
|
||||||
sort_order REAL NOT NULL DEFAULT 0,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE setups (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE setup_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
classification TEXT NOT NULL DEFAULT 'base'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
expires_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
sqlite.run(`
|
|
||||||
CREATE TABLE api_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
key_hash TEXT NOT NULL,
|
|
||||||
key_prefix TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const db = drizzle(sqlite, { schema });
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Apply all migrations to create tables
|
||||||
|
migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
|
||||||
// Seed default Uncategorized category
|
// Seed default Uncategorized category
|
||||||
db.insert(schema.categories)
|
db.insert(schema.categories)
|
||||||
.values({ name: "Uncategorized", icon: "package" })
|
.values({ name: "Uncategorized", icon: "package" })
|
||||||
|
|||||||
37
tests/lib/params.test.ts
Normal file
37
tests/lib/params.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { parseId } from "../../src/server/lib/params";
|
||||||
|
|
||||||
|
describe("parseId", () => {
|
||||||
|
it("returns number for valid positive integers", () => {
|
||||||
|
expect(parseId("1")).toBe(1);
|
||||||
|
expect(parseId("42")).toBe(42);
|
||||||
|
expect(parseId("999")).toBe(999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for zero", () => {
|
||||||
|
expect(parseId("0")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for negative numbers", () => {
|
||||||
|
expect(parseId("-1")).toBeNull();
|
||||||
|
expect(parseId("-100")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for decimals", () => {
|
||||||
|
expect(parseId("1.5")).toBeNull();
|
||||||
|
expect(parseId("3.14")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-numeric strings", () => {
|
||||||
|
expect(parseId("abc")).toBeNull();
|
||||||
|
expect(parseId("")).toBeNull();
|
||||||
|
expect(parseId("hello")).toBeNull();
|
||||||
|
expect(parseId("12abc")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for special values", () => {
|
||||||
|
expect(parseId("NaN")).toBeNull();
|
||||||
|
expect(parseId("Infinity")).toBeNull();
|
||||||
|
expect(parseId("-Infinity")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
85
tests/middleware/rateLimit.test.ts
Normal file
85
tests/middleware/rateLimit.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import {
|
||||||
|
_resetForTesting,
|
||||||
|
rateLimit,
|
||||||
|
} from "../../src/server/middleware/rateLimit";
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = new Hono();
|
||||||
|
app.post("/login", rateLimit, (c) => c.json({ ok: true }));
|
||||||
|
app.post("/setup", rateLimit, (c) => c.json({ ok: true }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(app: Hono, path: string, ip = "127.0.0.1") {
|
||||||
|
return app.request(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-forwarded-for": ip },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rateLimit middleware", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows first request through", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows up to 5 requests", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 after 5 requests", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("Too many attempts. Try again later.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Retry-After header on 429", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const res = await makeRequest(app, "/login");
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
const retryAfter = res.headers.get("Retry-After");
|
||||||
|
expect(retryAfter).toBeTruthy();
|
||||||
|
expect(Number(retryAfter)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks different IPs independently", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login", "10.0.0.1");
|
||||||
|
}
|
||||||
|
const blocked = await makeRequest(app, "/login", "10.0.0.1");
|
||||||
|
expect(blocked.status).toBe(429);
|
||||||
|
const allowed = await makeRequest(app, "/login", "10.0.0.2");
|
||||||
|
expect(allowed.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks different paths independently", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await makeRequest(app, "/login");
|
||||||
|
}
|
||||||
|
const blockedLogin = await makeRequest(app, "/login");
|
||||||
|
expect(blockedLogin.status).toBe(429);
|
||||||
|
const allowedSetup = await makeRequest(app, "/setup");
|
||||||
|
expect(allowedSetup.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
tests/routes/params.test.ts
Normal file
81
tests/routes/params.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { categoryRoutes } from "../../src/server/routes/categories";
|
||||||
|
import { itemRoutes } from "../../src/server/routes/items";
|
||||||
|
import { setupRoutes } from "../../src/server/routes/setups";
|
||||||
|
import { threadRoutes } from "../../src/server/routes/threads";
|
||||||
|
import { createTestDb } from "../helpers/db";
|
||||||
|
|
||||||
|
function createTestApp() {
|
||||||
|
const db = createTestDb();
|
||||||
|
const app = new Hono();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("db", db);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.route("/api/items", itemRoutes);
|
||||||
|
app.route("/api/categories", categoryRoutes);
|
||||||
|
app.route("/api/threads", threadRoutes);
|
||||||
|
app.route("/api/setups", setupRoutes);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Invalid ID parameter handling", () => {
|
||||||
|
let app: Hono;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("items", () => {
|
||||||
|
it("GET /api/items/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toContain("Invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/items/0 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/0");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/items/-1 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/items/-1");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categories", () => {
|
||||||
|
it("DELETE /api/categories/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/categories/abc", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("threads", () => {
|
||||||
|
it("GET /api/threads/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/threads/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/threads/1.5 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/threads/1.5");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setups", () => {
|
||||||
|
it("GET /api/setups/abc returns 400", async () => {
|
||||||
|
const res = await app.request("/api/setups/abc");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/setups/0 returns 400", async () => {
|
||||||
|
const res = await app.request("/api/setups/0");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user