docs: complete project research
This commit is contained in:
@@ -1,362 +1,677 @@
|
||||
# Architecture Research
|
||||
|
||||
**Domain:** Single-user gear management and purchase planning web app
|
||||
**Researched:** 2026-03-14
|
||||
**Domain:** Gear management app -- v1.2 feature integration (search/filter, weight classification, weight distribution charts, candidate status tracking, weight unit selection)
|
||||
**Researched:** 2026-03-16
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Standard Architecture
|
||||
## System Overview: Integration Map
|
||||
|
||||
### System Overview
|
||||
The v1.2 features integrate across all existing layers. This diagram shows where new components slot in relative to the current architecture.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client (Browser) │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ Dashboard │ │Collection │ │ Threads │ │ Setups │ │
|
||||
│ │ Page │ │ Page │ │ Page │ │ Page │ │
|
||||
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌─────┴──────────────┴──────────────┴──────────────┴─────┐ │
|
||||
│ │ Shared UI Components │ │
|
||||
│ │ (ItemCard, ComparisonTable, WeightBadge, CostBadge) │ │
|
||||
│ └────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ fetch() │
|
||||
├───────────────────────────┼────────────────────────────────────┤
|
||||
│ Bun.serve() │
|
||||
│ ┌────────────────────────┴───────────────────────────────┐ │
|
||||
│ │ API Routes Layer │ │
|
||||
│ │ /api/items /api/threads /api/setups │ │
|
||||
│ │ /api/stats /api/candidates /api/images │ │
|
||||
│ └────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────┴───────────────────────────────┐ │
|
||||
│ │ Service Layer │ │
|
||||
│ │ ItemService ThreadService SetupService StatsService│ │
|
||||
│ └────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────┴───────────────────────────────┐ │
|
||||
│ │ Data Access (Drizzle ORM) │ │
|
||||
│ │ Schema + Queries │ │
|
||||
│ └────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────┴───────────────────────────────┐ │
|
||||
│ │ SQLite (bun:sqlite) │ │
|
||||
│ │ gearbox.db file │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
CLIENT LAYER
|
||||
+-----------------------------------------------------------------+
|
||||
| Routes |
|
||||
| +------------+ +------------+ +------------+ |
|
||||
| | /collection| | /threads/$ | | /setups/$ | |
|
||||
| | [MODIFIED] | | [MODIFIED] | | [MODIFIED] | |
|
||||
| +------+-----+ +------+-----+ +------+-----+ |
|
||||
| | | | |
|
||||
| Components (NEW) |
|
||||
| +------------+ +--------------+ +-------------+ |
|
||||
| | SearchBar | | WeightChart | | UnitSelector| |
|
||||
| +------------+ +--------------+ +-------------+ |
|
||||
| |
|
||||
| Components (MODIFIED) |
|
||||
| +------------+ +--------------+ +-------------+ |
|
||||
| | ItemCard | | CandidateCard| | TotalsBar | |
|
||||
| | ItemForm | | CandidateForm| | CategoryHdr | |
|
||||
| +------------+ +--------------+ +-------------+ |
|
||||
| |
|
||||
| Hooks (NEW) Hooks (MODIFIED) |
|
||||
| +------------------+ +------------------+ |
|
||||
| | useFormatWeight | | useSetups | |
|
||||
| +------------------+ | useThreads | |
|
||||
| +------------------+ |
|
||||
| |
|
||||
| Lib (MODIFIED) Stores (NO CHANGE) |
|
||||
| +------------------+ +------------------+ |
|
||||
| | formatters.ts | | uiStore.ts | |
|
||||
| +------------------+ +------------------+ |
|
||||
+-----------------------------------------------------------------+
|
||||
| API Layer: lib/api.ts -- NO CHANGE |
|
||||
+-----------------------------------------------------------------+
|
||||
SERVER LAYER
|
||||
| Routes (MODIFIED) |
|
||||
| +------------+ +------------+ +------------+ |
|
||||
| | items.ts | | threads.ts | | setups.ts | |
|
||||
| | (no change)| | (no change)| | +PATCH item| |
|
||||
| +------+-----+ +------+-----+ +------+-----+ |
|
||||
| | | | |
|
||||
| Services (MODIFIED) |
|
||||
| +------------+ +--------------+ +--------------+ |
|
||||
| | item.svc | | thread.svc | | setup.svc | |
|
||||
| | (no change)| | +cand.status | | +weightClass | |
|
||||
| +------+-----+ +------+-------+ +------+-------+ |
|
||||
+---------+----------------+----------------+---------------------+
|
||||
DATABASE LAYER
|
||||
| schema.ts (MODIFIED) |
|
||||
| +----------------------------------------------------------+ |
|
||||
| | setup_items: +weight_class TEXT DEFAULT 'base' | |
|
||||
| | thread_candidates: +status TEXT DEFAULT 'researching' | |
|
||||
| | settings: weightUnit row (uses existing key-value table) | |
|
||||
| +----------------------------------------------------------+ |
|
||||
| |
|
||||
| tests/helpers/db.ts (MODIFIED -- add new columns) |
|
||||
+-----------------------------------------------------------------+
|
||||
```
|
||||
|
||||
This is a monolithic full-stack app running on a single Bun process. No microservices, no separate API server, no Docker. Bun's built-in fullstack dev server handles both static asset bundling and API routes from a single `Bun.serve()` call. SQLite is the database -- embedded, zero-config, accessed through Bun's native `bun:sqlite` module (3-6x faster than better-sqlite3).
|
||||
## Feature-by-Feature Integration
|
||||
|
||||
### Component Responsibilities
|
||||
### Feature 1: Search Items and Filter by Category
|
||||
|
||||
| Component | Responsibility | Typical Implementation |
|
||||
|-----------|----------------|------------------------|
|
||||
| Dashboard Page | Entry point, summary cards, navigation | React page showing item count, active threads, setup stats |
|
||||
| Collection Page | CRUD for gear items, filtering, sorting | React page with list/grid views, item detail modal |
|
||||
| Threads Page | Purchase research threads with candidates | React page with thread list, candidate comparison view |
|
||||
| Setups Page | Compose named setups from collection items | React page with drag/drop or select-to-add from collection |
|
||||
| API Routes | HTTP endpoints for all data operations | Bun.serve() route handlers, REST-style |
|
||||
| Service Layer | Business logic, calculations (weight/cost totals) | TypeScript modules with domain logic |
|
||||
| Data Access | Schema definition, queries, migrations | Drizzle ORM with SQLite dialect |
|
||||
| SQLite DB | Persistent storage | Single file, bun:sqlite native module |
|
||||
| Image Storage | Photo uploads for gear items | Local filesystem (`./uploads/`) served as static files |
|
||||
**Scope:** Client-side filtering of already-fetched data. No server changes needed -- the collection is small enough (single user) that client-side filtering is both simpler and faster.
|
||||
|
||||
## Recommended Project Structure
|
||||
**Integration points:**
|
||||
|
||||
| Layer | File | Change Type | Details |
|
||||
|-------|------|-------------|---------|
|
||||
| Client | `routes/collection/index.tsx` | MODIFY | Add search input and category filter dropdown above the gear grid in `CollectionView` |
|
||||
| Client | NEW `components/SearchBar.tsx` | NEW | Reusable search input component with clear button |
|
||||
| Client | `hooks/useItems.ts` | NO CHANGE | Already returns all items; filtering happens in the route |
|
||||
|
||||
**Data flow:**
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.tsx # Bun.serve() entry point, route registration
|
||||
├── pages/ # HTML entrypoints for each page
|
||||
│ ├── index.html # Dashboard
|
||||
│ ├── collection.html # Collection page
|
||||
│ ├── threads.html # Planning threads page
|
||||
│ └── setups.html # Setups page
|
||||
├── client/ # React frontend code
|
||||
│ ├── components/ # Shared UI components
|
||||
│ │ ├── ItemCard.tsx
|
||||
│ │ ├── WeightBadge.tsx
|
||||
│ │ ├── CostBadge.tsx
|
||||
│ │ ├── ComparisonTable.tsx
|
||||
│ │ ├── StatusBadge.tsx
|
||||
│ │ └── Layout.tsx
|
||||
│ ├── pages/ # Page-level React components
|
||||
│ │ ├── Dashboard.tsx
|
||||
│ │ ├── Collection.tsx
|
||||
│ │ ├── ThreadList.tsx
|
||||
│ │ ├── ThreadDetail.tsx
|
||||
│ │ ├── SetupList.tsx
|
||||
│ │ └── SetupDetail.tsx
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── useItems.ts
|
||||
│ │ ├── useThreads.ts
|
||||
│ │ └── useSetups.ts
|
||||
│ └── lib/ # Client utilities
|
||||
│ ├── api.ts # Fetch wrapper for API calls
|
||||
│ └── formatters.ts # Weight/cost formatting helpers
|
||||
├── server/ # Backend code
|
||||
│ ├── routes/ # API route handlers
|
||||
│ │ ├── items.ts
|
||||
│ │ ├── threads.ts
|
||||
│ │ ├── candidates.ts
|
||||
│ │ ├── setups.ts
|
||||
│ │ ├── images.ts
|
||||
│ │ └── stats.ts
|
||||
│ └── services/ # Business logic
|
||||
│ ├── item.service.ts
|
||||
│ ├── thread.service.ts
|
||||
│ ├── setup.service.ts
|
||||
│ └── stats.service.ts
|
||||
├── db/ # Database layer
|
||||
│ ├── schema.ts # Drizzle table definitions
|
||||
│ ├── index.ts # Database connection singleton
|
||||
│ ├── seed.ts # Optional dev seed data
|
||||
│ └── migrations/ # Drizzle Kit generated migrations
|
||||
├── shared/ # Types shared between client and server
|
||||
│ └── types.ts # Item, Thread, Candidate, Setup types
|
||||
uploads/ # Gear photos (gitignored, outside src/)
|
||||
drizzle.config.ts # Drizzle Kit config
|
||||
CollectionView (owns search/filter state via useState)
|
||||
|
|
||||
+-- SearchBar (controlled input, calls setSearchTerm)
|
||||
+-- CategoryFilter (dropdown from useCategories, calls setCategoryFilter)
|
||||
|
|
||||
+-- Items = useItems().data
|
||||
.filter(item => matchesSearch(item.name, searchTerm))
|
||||
.filter(item => !categoryFilter || item.categoryId === categoryFilter)
|
||||
|
|
||||
+-- Grouped by category -> rendered as before
|
||||
```
|
||||
|
||||
### Structure Rationale
|
||||
**Why client-side:** The `useItems()` hook already fetches all items. For a single-user app, even 500 items is trivially fast to filter in memory. Adding server-side search would mean new API parameters, new query logic, and pagination -- all unnecessary complexity. If the collection grows beyond ~2000 items someday, server-side search can be added to the existing `getAllItems` service function by accepting optional `search` and `categoryId` parameters and adding Drizzle `like()` + `eq()` conditions.
|
||||
|
||||
- **`client/` and `server/` separation:** Clear boundary between browser code and server code. Both import from `shared/` and `db/` (server only) but never from each other.
|
||||
- **`pages/` HTML entrypoints:** Bun's fullstack server uses HTML files as route entrypoints. Each HTML file imports its corresponding React component tree.
|
||||
- **`server/routes/` + `server/services/`:** Routes handle HTTP concerns (parsing params, status codes). Services handle business logic (calculating totals, validating state transitions). This prevents bloated route handlers.
|
||||
- **`db/schema.ts` as single source of truth:** All table definitions in one file. Drizzle infers TypeScript types from the schema, so types flow from DB to API to client.
|
||||
- **`shared/types.ts`:** API response types and domain enums shared between client and server. Avoids type drift.
|
||||
- **`uploads/` outside `src/`:** User-uploaded images are not source code. Served as static files by Bun.
|
||||
**Pattern -- filtered items with useMemo:**
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Pattern 1: Bun Fullstack Monolith
|
||||
|
||||
**What:** Single Bun.serve() process serves HTML pages, bundled React assets, and API routes. No separate frontend dev server, no proxy config, no CORS.
|
||||
**When to use:** Single-user apps, prototypes, small team projects where deployment simplicity matters.
|
||||
**Trade-offs:** Extremely simple to deploy (one process, one command), but no horizontal scaling. For GearBox this is ideal -- single user, no scaling needed.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// src/index.tsx
|
||||
import homepage from "./pages/index.html";
|
||||
import collectionPage from "./pages/collection.html";
|
||||
import { itemRoutes } from "./server/routes/items";
|
||||
import { threadRoutes } from "./server/routes/threads";
|
||||
// In CollectionView component
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": homepage,
|
||||
"/collection": collectionPage,
|
||||
...itemRoutes,
|
||||
...threadRoutes,
|
||||
},
|
||||
development: true,
|
||||
});
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!items) return [];
|
||||
return items
|
||||
.filter(item => {
|
||||
if (!searchTerm) return true;
|
||||
return item.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
})
|
||||
.filter(item => {
|
||||
if (!categoryFilter) return true;
|
||||
return item.categoryId === categoryFilter;
|
||||
});
|
||||
}, [items, searchTerm, categoryFilter]);
|
||||
```
|
||||
|
||||
### Pattern 2: Service Layer for Business Logic
|
||||
No debounce library needed -- `useMemo` re-computes on keystroke, and filtering an in-memory array of <1000 items is sub-millisecond. Debounce is only needed if triggering API calls.
|
||||
|
||||
**What:** Route handlers delegate to service modules that contain domain logic. Services are pure functions or classes that take data in and return results, with no HTTP awareness.
|
||||
**When to use:** When routes would otherwise contain calculation logic (weight totals, cost impact analysis, status transitions).
|
||||
**Trade-offs:** Slightly more files, but logic is testable without HTTP mocking and reusable across routes.
|
||||
**The category filter already exists** in `PlanningView` (lines 191-209 and 277-290 in `collection/index.tsx`). The same pattern should be reused for the gear tab with an icon-aware dropdown replacing the plain `<select>`. The existing `useCategories` hook provides the category list.
|
||||
|
||||
**Planning category filter upgrade:** The current plain `<select>` in PlanningView should be upgraded to an icon-aware dropdown that shows Lucide icons next to category names. This is a shared component that both the gear tab filter and the planning tab filter can use.
|
||||
|
||||
---
|
||||
|
||||
### Feature 2: Weight Classification (Base / Worn / Consumable)
|
||||
|
||||
**Scope:** Per-item-per-setup classification. An item's classification depends on the setup context (a rain jacket might be "worn" in one setup and "base" in another). This means the classification lives on the `setup_items` join table, not on the `items` table.
|
||||
|
||||
**Integration points:**
|
||||
|
||||
| Layer | File | Change Type | Details |
|
||||
|-------|------|-------------|---------|
|
||||
| DB | `schema.ts` | MODIFY | Add `weightClass` column to `setup_items` |
|
||||
| DB | Drizzle migration | NEW | `ALTER TABLE setup_items ADD COLUMN weight_class TEXT NOT NULL DEFAULT 'base'` |
|
||||
| Shared | `schemas.ts` | MODIFY | Add `weightClass` to sync schema, add update schema |
|
||||
| Shared | `types.ts` | NO CHANGE | Types auto-infer from Drizzle schema |
|
||||
| Server | `setup.service.ts` | MODIFY | `getSetupWithItems` returns `weightClass`; add `updateSetupItemClass` function |
|
||||
| Server | `routes/setups.ts` | MODIFY | Add `PATCH /:id/items/:itemId` for classification update |
|
||||
| Client | `hooks/useSetups.ts` | MODIFY | `SetupItemWithCategory` type adds `weightClass`; add `useUpdateSetupItemClass` mutation |
|
||||
| Client | `routes/setups/$setupId.tsx` | MODIFY | Show classification badges, add toggle UI, compute classification totals |
|
||||
| Client | `components/ItemCard.tsx` | MODIFY | Accept optional `weightClass` prop for setup context |
|
||||
| Test | `tests/helpers/db.ts` | MODIFY | Add `weight_class` column to `setup_items` CREATE TABLE |
|
||||
|
||||
**Schema change:**
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// server/services/setup.service.ts
|
||||
export function calculateSetupTotals(items: Item[]): SetupTotals {
|
||||
return {
|
||||
totalWeight: items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0),
|
||||
totalCost: items.reduce((sum, i) => sum + (i.priceCents ?? 0), 0),
|
||||
itemCount: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeCandidateImpact(
|
||||
setup: Setup,
|
||||
candidate: Candidate
|
||||
): Impact {
|
||||
const currentTotals = calculateSetupTotals(setup.items);
|
||||
return {
|
||||
weightDelta: (candidate.weightGrams ?? 0) - (setup.replacingItem?.weightGrams ?? 0),
|
||||
costDelta: (candidate.priceCents ?? 0) - (setup.replacingItem?.priceCents ?? 0),
|
||||
newTotalWeight: currentTotals.totalWeight + this.weightDelta,
|
||||
newTotalCost: currentTotals.totalCost + this.costDelta,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Drizzle ORM with bun:sqlite
|
||||
|
||||
**What:** Drizzle provides type-safe SQL query building and schema-as-code migrations on top of Bun's native SQLite. Schema definitions double as TypeScript type sources.
|
||||
**When to use:** Any Bun + SQLite project that wants type safety without the overhead of a full ORM like Prisma.
|
||||
**Trade-offs:** Lightweight (no query engine, no runtime overhead). SQL-first philosophy means you write SQL-like code, not abstract methods. Migration tooling via Drizzle Kit is solid but simpler than Prisma Migrate.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// db/schema.ts
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const items = sqliteTable("items", {
|
||||
// In schema.ts -- setup_items table
|
||||
export const setupItems = sqliteTable("setup_items", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
category: text("category"),
|
||||
weightGrams: integer("weight_grams"),
|
||||
priceCents: integer("price_cents"),
|
||||
purchaseSource: text("purchase_source"),
|
||||
productUrl: text("product_url"),
|
||||
notes: text("notes"),
|
||||
imageFilename: text("image_filename"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
setupId: integer("setup_id")
|
||||
.notNull()
|
||||
.references(() => setups.id, { onDelete: "cascade" }),
|
||||
itemId: integer("item_id")
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: "cascade" }),
|
||||
weightClass: text("weight_class").notNull().default("base"),
|
||||
// Values: "base" | "worn" | "consumable"
|
||||
});
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
**Why on setup_items, not items:** LighterPack and all serious gear tracking tools classify items per-loadout. A sleeping bag is "base weight" in a backpacking setup but might not be in a day hike setup. The same pair of hiking boots is "worn weight" in every setup, but this is a user choice per context. Storing on the join table preserves this flexibility at zero additional complexity -- the `setup_items` table already exists.
|
||||
|
||||
### Request Flow
|
||||
**New endpoint for classification update:**
|
||||
|
||||
```
|
||||
[User clicks "Add Item"]
|
||||
|
|
||||
[React component] --> fetch("/api/items", { method: "POST", body })
|
||||
|
|
||||
[Bun.serve route handler] --> validates input, calls service
|
||||
|
|
||||
[ItemService.create()] --> business logic, defaults
|
||||
|
|
||||
[Drizzle ORM] --> db.insert(items).values(...)
|
||||
|
|
||||
[bun:sqlite] --> writes to gearbox.db
|
||||
|
|
||||
[Response] <-- { id, name, ... } JSON <-- 201 Created
|
||||
|
|
||||
[React state update] --> re-renders item list
|
||||
The existing sync pattern (delete-all + re-insert) would destroy classification data on every item add/remove. Instead, add a targeted update endpoint:
|
||||
|
||||
```typescript
|
||||
// In setup.service.ts
|
||||
export function updateSetupItemClass(
|
||||
db: Db,
|
||||
setupId: number,
|
||||
itemId: number,
|
||||
weightClass: "base" | "worn" | "consumable",
|
||||
) {
|
||||
return db
|
||||
.update(setupItems)
|
||||
.set({ weightClass })
|
||||
.where(
|
||||
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
```
|
||||
|
||||
### Key Data Flows
|
||||
|
||||
1. **Collection CRUD:** Straightforward REST. Client sends item data, server validates, persists, returns updated item. Client hooks (useItems) manage local state.
|
||||
|
||||
2. **Thread lifecycle:** Create thread -> Add candidates -> Compare -> Resolve (pick winner). Resolution triggers: candidate becomes a collection item, thread status changes to "resolved", other candidates marked as rejected. This is the most stateful flow.
|
||||
|
||||
3. **Setup composition:** User selects items from collection to add to a named setup. Server calculates aggregate weight/cost. When viewing a thread candidate, "impact on setup" is computed by comparing candidate against current setup totals (or against a specific item being replaced).
|
||||
|
||||
4. **Dashboard aggregation:** Dashboard fetches summary stats via `/api/stats` -- total items, total collection value, active threads count, setup count. This is a read-only aggregation endpoint, not a separate data store.
|
||||
|
||||
5. **Image upload:** Multipart form upload to `/api/images`, saved to `./uploads/` with a UUID filename. The filename is stored on the item record. Images served as static files.
|
||||
|
||||
### Data Model Relationships
|
||||
|
||||
```
|
||||
items (gear collection)
|
||||
|
|
||||
|-- 1:N --> setup_items (junction) <-- N:1 -- setups
|
||||
|
|
||||
|-- 1:N --> thread_candidates (when resolved, candidate -> item)
|
||||
|
||||
threads (planning threads)
|
||||
|
|
||||
|-- 1:N --> candidates (potential purchases)
|
||||
|-- status: researching | ordered | arrived
|
||||
|-- resolved_as: winner | rejected | null
|
||||
|
||||
setups
|
||||
|
|
||||
|-- N:M --> items (via setup_items junction table)
|
||||
```typescript
|
||||
// In routes/setups.ts -- new PATCH route
|
||||
app.patch("/:setupId/items/:itemId", zValidator("json", updateSetupItemClassSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const setupId = Number(c.req.param("setupId"));
|
||||
const itemId = Number(c.req.param("itemId"));
|
||||
const { weightClass } = c.req.valid("json");
|
||||
updateSetupItemClass(db, setupId, itemId, weightClass);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
### State Management
|
||||
**Also update syncSetupItems** to preserve existing classifications or accept them:
|
||||
|
||||
No global state library needed. React hooks + fetch are sufficient for a single-user app with this complexity level.
|
||||
|
||||
```
|
||||
[React Hook per domain] [API call] [Server] [SQLite]
|
||||
useItems() state --------> GET /api/items --> route handler --> SELECT
|
||||
| |
|
||||
|<-- setItems(data) <--- JSON response <--- query result <------+
|
||||
```typescript
|
||||
// Updated syncSetupItems to accept optional weightClass
|
||||
export function syncSetupItems(
|
||||
db: Db,
|
||||
setupId: number,
|
||||
items: Array<{ itemId: number; weightClass?: string }>,
|
||||
) {
|
||||
return db.transaction((tx) => {
|
||||
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
|
||||
for (const item of items) {
|
||||
tx.insert(setupItems)
|
||||
.values({
|
||||
setupId,
|
||||
itemId: item.itemId,
|
||||
weightClass: item.weightClass ?? "base",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Each page manages its own state via custom hooks (`useItems`, `useThreads`, `useSetups`). No Redux, no Zustand. If a mutation on one page affects another (e.g., resolving a thread adds an item to collection), the target page simply refetches on mount.
|
||||
**Sync schema update:**
|
||||
|
||||
## Scaling Considerations
|
||||
```typescript
|
||||
export const syncSetupItemsSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
itemId: z.number().int().positive(),
|
||||
weightClass: z.enum(["base", "worn", "consumable"]).default("base"),
|
||||
})),
|
||||
});
|
||||
```
|
||||
|
||||
| Scale | Architecture Adjustments |
|
||||
|-------|--------------------------|
|
||||
| Single user (GearBox) | SQLite + single Bun process. Zero infrastructure. This is the target. |
|
||||
| 1-10 users | Still fine with SQLite in WAL mode. Add basic auth if needed. |
|
||||
| 100+ users | Switch to PostgreSQL, add connection pooling, consider separate API server. Not relevant for this project. |
|
||||
This is a **breaking change** to the sync API shape (from `{ itemIds: number[] }` to `{ items: [...] }`). The single call site is `useSyncSetupItems` in `useSetups.ts`, called from `ItemPicker.tsx`.
|
||||
|
||||
### Scaling Priorities
|
||||
**Client-side classification totals** are computed from the setup items array, not from a separate API:
|
||||
|
||||
1. **First bottleneck:** Image storage. If users upload many high-res photos, disk fills up. Mitigation: resize on upload (sharp library), limit file sizes.
|
||||
2. **Second bottleneck:** SQLite write contention under concurrent access. Not a concern for single-user. If it ever matters, switch to WAL mode or PostgreSQL.
|
||||
```typescript
|
||||
const baseWeight = setup.items
|
||||
.filter(i => i.weightClass === "base")
|
||||
.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
||||
|
||||
## Anti-Patterns
|
||||
const wornWeight = setup.items
|
||||
.filter(i => i.weightClass === "worn")
|
||||
.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
||||
|
||||
### Anti-Pattern 1: Storing Money as Floats
|
||||
const consumableWeight = setup.items
|
||||
.filter(i => i.weightClass === "consumable")
|
||||
.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
||||
```
|
||||
|
||||
**What people do:** Use `float` or JavaScript `number` for prices (e.g., `19.99`).
|
||||
**Why it's wrong:** Floating point arithmetic causes rounding errors. `0.1 + 0.2 !== 0.3`. Price calculations silently drift.
|
||||
**Do this instead:** Store prices as integers in cents (`1999` for $19.99). Format for display only in the UI layer. The schema uses `priceCents: integer`.
|
||||
**UI for classification toggle:** A three-segment toggle on each item card within the setup detail view. Clicking a segment calls `useUpdateSetupItemClass`. The three segments use the same pill-tab pattern already used for Active/Resolved in PlanningView.
|
||||
|
||||
### Anti-Pattern 2: Overengineering State Management
|
||||
---
|
||||
|
||||
**What people do:** Install Redux/Zustand/Jotai for a single-user CRUD app, create elaborate store slices, actions, reducers.
|
||||
**Why it's wrong:** Adds complexity with zero benefit when there is one user and no shared state across tabs or real-time updates.
|
||||
**Do this instead:** Use React hooks with fetch. `useState` + `useEffect` + a thin API wrapper. Refetch on mount. Keep it boring.
|
||||
### Feature 3: Weight Distribution Visualization
|
||||
|
||||
### Anti-Pattern 3: SPA with Client-Side Routing for Everything
|
||||
**Scope:** Donut chart showing weight breakdown by category (on collection page) and by classification (on setup detail page). Uses `react-minimal-pie-chart` (~2kB gzipped) instead of Recharts (~45kB) because this is the only chart in the app.
|
||||
|
||||
**What people do:** Build a full SPA with React Router, lazy loading, code splitting for 4-5 pages.
|
||||
**Why it's wrong:** Bun's fullstack server already handles page routing via HTML entrypoints. Adding client-side routing means duplicating routing logic, losing Bun's built-in asset optimization per page, and adding bundle complexity.
|
||||
**Do this instead:** Use Bun's HTML-based routing. Each page is a separate HTML entrypoint with its own React tree. Navigation between pages is standard `<a href>` links. Keep client-side routing for in-page state (e.g., tabs within thread detail) only.
|
||||
**Integration points:**
|
||||
|
||||
### Anti-Pattern 4: Storing Computed Aggregates in the Database
|
||||
| Layer | File | Change Type | Details |
|
||||
|-------|------|-------------|---------|
|
||||
| Package | `package.json` | MODIFY | Add `react-minimal-pie-chart` dependency |
|
||||
| Client | NEW `components/WeightChart.tsx` | NEW | Reusable donut chart component |
|
||||
| Client | `routes/collection/index.tsx` | MODIFY | Add chart above category list in gear tab |
|
||||
| Client | `routes/setups/$setupId.tsx` | MODIFY | Add classification breakdown chart |
|
||||
| Client | `hooks/useTotals.ts` | NO CHANGE | Already returns `CategoryTotals[]` with weights |
|
||||
|
||||
**What people do:** Store `totalWeight` and `totalCost` on the setup record, then try to keep them in sync when items change.
|
||||
**Why it's wrong:** Stale data, sync bugs, update anomalies. Items get edited but setup totals do not get recalculated.
|
||||
**Do this instead:** Compute totals on read. SQLite is fast enough for `SUM()` across a handful of items. Calculate in the service layer or as a SQL aggregate. For a single-user app with small datasets, this is effectively instant.
|
||||
**Why react-minimal-pie-chart over Recharts:** The app needs exactly one chart type (donut/pie). Recharts adds ~45kB gzipped for a full charting library when only the PieChart component is used. `react-minimal-pie-chart` is <3kB gzipped, has zero dependencies beyond React, supports donut charts via `lineWidth` prop, includes animation, and provides label support. It is the right tool for a focused need.
|
||||
|
||||
## Integration Points
|
||||
**Chart component pattern:**
|
||||
|
||||
### External Services
|
||||
```typescript
|
||||
// components/WeightChart.tsx
|
||||
import { PieChart } from "react-minimal-pie-chart";
|
||||
|
||||
| Service | Integration Pattern | Notes |
|
||||
|---------|---------------------|-------|
|
||||
| None for v1 | N/A | Single-user local app, no external APIs needed |
|
||||
| Product URLs | Outbound links only | Store URLs to retailer pages, no API scraping |
|
||||
interface WeightChartProps {
|
||||
segments: Array<{
|
||||
label: string;
|
||||
value: number; // weight in grams (always grams internally)
|
||||
color: string;
|
||||
}>;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
### Internal Boundaries
|
||||
export function WeightChart({ segments, size = 200 }: WeightChartProps) {
|
||||
const filtered = segments.filter(s => s.value > 0);
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
| Boundary | Communication | Notes |
|
||||
|----------|---------------|-------|
|
||||
| Client <-> Server | REST API (JSON over fetch) | No WebSockets needed, no real-time requirements |
|
||||
| Routes <-> Services | Direct function calls | Same process, no serialization overhead |
|
||||
| Services <-> Database | Drizzle ORM queries | Type-safe, no raw SQL strings |
|
||||
| Server <-> Filesystem | Image read/write | `./uploads/` directory for gear photos |
|
||||
return (
|
||||
<PieChart
|
||||
data={filtered.map(s => ({
|
||||
title: s.label,
|
||||
value: s.value,
|
||||
color: s.color,
|
||||
}))}
|
||||
lineWidth={35} // donut style
|
||||
paddingAngle={2}
|
||||
rounded
|
||||
animate
|
||||
animationDuration={500}
|
||||
style={{ height: size, width: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Build Order (Dependency Chain)
|
||||
**Two usage contexts:**
|
||||
|
||||
The architecture implies this build sequence:
|
||||
1. **Collection page** -- weight by category. Data source: `useTotals().data.categories`. Each `CategoryTotals` already has `totalWeight` and `categoryName`. Assign a consistent color per category (use category index mapped to a palette array).
|
||||
|
||||
1. **Database schema + Drizzle setup** -- Everything depends on the data model. Define tables for items, threads, candidates, setups, setup_items first.
|
||||
2. **API routes for items (CRUD)** -- The core entity. Threads and setups reference items.
|
||||
3. **Collection UI** -- First visible feature. Validates the data model and API work end-to-end.
|
||||
4. **Thread + candidate API and UI** -- Depends on items existing to resolve candidates into the collection.
|
||||
5. **Setup composition API and UI** -- Depends on items existing to compose into setups.
|
||||
6. **Dashboard** -- Aggregates stats from all other entities. Build last since it reads from everything.
|
||||
7. **Polish: image upload, impact calculations, status tracking** -- Enhancement layer on top of working CRUD.
|
||||
2. **Setup detail page** -- weight by classification. Data source: computed from `setup.items` grouping by `weightClass`. Three fixed colors for base/worn/consumable.
|
||||
|
||||
This ordering means each phase produces a usable increment: after phase 3 you have a working gear catalog, after phase 4 you can plan purchases, after phase 5 you can compose setups.
|
||||
**Color palette for categories:**
|
||||
|
||||
```typescript
|
||||
const CATEGORY_COLORS = [
|
||||
"#6B7280", "#3B82F6", "#10B981", "#F59E0B",
|
||||
"#EF4444", "#8B5CF6", "#EC4899", "#14B8A6",
|
||||
"#F97316", "#6366F1", "#84CC16", "#06B6D4",
|
||||
];
|
||||
|
||||
function getCategoryColor(index: number): string {
|
||||
return CATEGORY_COLORS[index % CATEGORY_COLORS.length];
|
||||
}
|
||||
```
|
||||
|
||||
**Classification colors (matching the app's muted palette):**
|
||||
|
||||
```typescript
|
||||
const CLASSIFICATION_COLORS = {
|
||||
base: "#6B7280", // gray -- the core pack weight
|
||||
worn: "#3B82F6", // blue -- on your body
|
||||
consumable: "#F59E0B", // amber -- gets used up
|
||||
};
|
||||
```
|
||||
|
||||
**Chart placement:** On the collection page, the chart appears as a compact summary card above the category-grouped items, alongside the global totals. On the setup detail page, it appears in the sticky sub-bar area or as a collapsible section showing base/worn/consumable breakdown with a legend. Keep it compact -- this is a supplementary visualization, not the primary UI.
|
||||
|
||||
---
|
||||
|
||||
### Feature 4: Candidate Status Tracking
|
||||
|
||||
**Scope:** Track candidate lifecycle from "researching" through "ordered" to "arrived". This is a column on the `thread_candidates` table, displayed as a badge on `CandidateCard`, and editable inline.
|
||||
|
||||
**Integration points:**
|
||||
|
||||
| Layer | File | Change Type | Details |
|
||||
|-------|------|-------------|---------|
|
||||
| DB | `schema.ts` | MODIFY | Add `status` column to `thread_candidates` |
|
||||
| DB | Drizzle migration | NEW | `ALTER TABLE thread_candidates ADD COLUMN status TEXT NOT NULL DEFAULT 'researching'` |
|
||||
| Shared | `schemas.ts` | MODIFY | Add `status` to candidate schemas |
|
||||
| Server | `thread.service.ts` | MODIFY | Include `status` in candidate creates and updates |
|
||||
| Server | `routes/threads.ts` | NO CHANGE | Already passes through all candidate fields |
|
||||
| Client | `hooks/useThreads.ts` | MODIFY | `CandidateWithCategory` type adds `status` |
|
||||
| Client | `hooks/useCandidates.ts` | NO CHANGE | `useUpdateCandidate` already handles partial updates |
|
||||
| Client | `components/CandidateCard.tsx` | MODIFY | Show status badge, add click-to-cycle |
|
||||
| Client | `components/CandidateForm.tsx` | MODIFY | Add status selector to form |
|
||||
| Test | `tests/helpers/db.ts` | MODIFY | Add `status` column to `thread_candidates` CREATE TABLE |
|
||||
|
||||
**Schema change:**
|
||||
|
||||
```typescript
|
||||
// In schema.ts -- thread_candidates table
|
||||
export const threadCandidates = sqliteTable("thread_candidates", {
|
||||
// ... existing fields ...
|
||||
status: text("status").notNull().default("researching"),
|
||||
// Values: "researching" | "ordered" | "arrived"
|
||||
});
|
||||
```
|
||||
|
||||
**Status badge colors (matching app's muted palette from v1.1):**
|
||||
|
||||
```typescript
|
||||
const CANDIDATE_STATUS_STYLES = {
|
||||
researching: "bg-gray-100 text-gray-600",
|
||||
ordered: "bg-amber-50 text-amber-600",
|
||||
arrived: "bg-green-50 text-green-600",
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Inline status cycling:** On `CandidateCard`, clicking the status badge cycles to the next state (researching -> ordered -> arrived). This calls the existing `useUpdateCandidate` mutation with just the status field. No new endpoint needed -- the `updateCandidate` service already accepts partial updates via `updateCandidateSchema.partial()`.
|
||||
|
||||
```typescript
|
||||
// In CandidateCard
|
||||
const STATUS_ORDER = ["researching", "ordered", "arrived"] as const;
|
||||
|
||||
function cycleStatus(current: string) {
|
||||
const idx = STATUS_ORDER.indexOf(current as any);
|
||||
return STATUS_ORDER[(idx + 1) % STATUS_ORDER.length];
|
||||
}
|
||||
|
||||
// onClick handler for status badge:
|
||||
updateCandidate.mutate({
|
||||
candidateId: id,
|
||||
status: cycleStatus(status),
|
||||
});
|
||||
```
|
||||
|
||||
**Candidate creation default:** New candidates default to "researching". The `createCandidateSchema` includes `status` as optional with default.
|
||||
|
||||
```typescript
|
||||
export const createCandidateSchema = z.object({
|
||||
// ... existing fields ...
|
||||
status: z.enum(["researching", "ordered", "arrived"]).default("researching"),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Feature 5: Weight Unit Selection
|
||||
|
||||
**Scope:** User preference stored in the `settings` table, applied globally across all weight displays. The database always stores grams -- unit conversion is a display-only concern handled in the client formatter.
|
||||
|
||||
**Integration points:**
|
||||
|
||||
| Layer | File | Change Type | Details |
|
||||
|-------|------|-------------|---------|
|
||||
| DB | `settings` table | NO SCHEMA CHANGE | Uses existing key-value `settings` table: `{ key: "weightUnit", value: "g" }` |
|
||||
| Server | Settings routes | NO CHANGE | Existing `GET/PUT /api/settings/:key` handles this |
|
||||
| Client | `hooks/useSettings.ts` | MODIFY | Add `useWeightUnit` convenience hook |
|
||||
| Client | `lib/formatters.ts` | MODIFY | `formatWeight` accepts unit parameter |
|
||||
| Client | NEW `hooks/useFormatWeight.ts` | NEW | Hook combining weight unit setting + formatter |
|
||||
| Client | ALL components showing weight | MODIFY | Use new formatting approach |
|
||||
| Client | `components/ItemForm.tsx` | MODIFY | Weight input label shows current unit, converts on submit |
|
||||
| Client | `components/CandidateForm.tsx` | MODIFY | Same as ItemForm |
|
||||
| Client | NEW `components/UnitSelector.tsx` | NEW | Unit picker UI (segmented control or dropdown) |
|
||||
|
||||
**Settings approach -- why not a new table:**
|
||||
|
||||
The `settings` table already exists with a `key/value` pattern, and `useSettings.ts` already has `useSetting(key)` and `useUpdateSetting`. Adding weight unit is:
|
||||
|
||||
```typescript
|
||||
// In useSettings.ts
|
||||
export function useWeightUnit() {
|
||||
return useSetting("weightUnit"); // Returns "g" | "oz" | "lb" | "kg" or null (default to "g")
|
||||
}
|
||||
```
|
||||
|
||||
**Conversion constants:**
|
||||
|
||||
```typescript
|
||||
const GRAMS_PER_UNIT = {
|
||||
g: 1,
|
||||
oz: 28.3495,
|
||||
lb: 453.592,
|
||||
kg: 1000,
|
||||
} as const;
|
||||
|
||||
type WeightUnit = keyof typeof GRAMS_PER_UNIT;
|
||||
```
|
||||
|
||||
**Modified formatWeight:**
|
||||
|
||||
```typescript
|
||||
export function formatWeight(
|
||||
grams: number | null | undefined,
|
||||
unit: WeightUnit = "g",
|
||||
): string {
|
||||
if (grams == null) return "--";
|
||||
const converted = grams / GRAMS_PER_UNIT[unit];
|
||||
const decimals = unit === "g" ? 0 : unit === "kg" ? 2 : 1;
|
||||
return `${converted.toFixed(decimals)} ${unit}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Threading unit through components -- custom hook approach:**
|
||||
|
||||
Create a `useFormatWeight()` hook. Components call it to get a unit-aware formatter. No React Context needed -- `useSetting()` already provides reactive data through React Query.
|
||||
|
||||
```typescript
|
||||
// hooks/useFormatWeight.ts
|
||||
import { useSetting } from "./useSettings";
|
||||
import { formatWeight as rawFormat, type WeightUnit } from "../lib/formatters";
|
||||
|
||||
export function useFormatWeight() {
|
||||
const { data: unitSetting } = useSetting("weightUnit");
|
||||
const unit = (unitSetting ?? "g") as WeightUnit;
|
||||
|
||||
return {
|
||||
unit,
|
||||
formatWeight: (grams: number | null | undefined) => rawFormat(grams, unit),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Components that display weight (ItemCard, CandidateCard, CategoryHeader, TotalsBar, SetupDetailPage) call `const { formatWeight } = useFormatWeight()` instead of importing `formatWeight` directly from `lib/formatters.ts`. This is 6-8 call sites to update.
|
||||
|
||||
**Weight input handling:** When the user enters weight in the form, the input accepts the selected unit and converts to grams before sending to the API. The label changes from "Weight (g)" to "Weight (oz)" etc.
|
||||
|
||||
```typescript
|
||||
// In ItemForm, the label reads from the hook
|
||||
const { unit } = useFormatWeight();
|
||||
// Label: `Weight (${unit})`
|
||||
|
||||
// On submit, before payload construction:
|
||||
const weightGrams = form.weightValue
|
||||
? Number(form.weightValue) * GRAMS_PER_UNIT[unit]
|
||||
: undefined;
|
||||
```
|
||||
|
||||
**When editing an existing item**, the form pre-fills by converting stored grams back to the display unit:
|
||||
|
||||
```typescript
|
||||
const displayWeight = item.weightGrams != null
|
||||
? (item.weightGrams / GRAMS_PER_UNIT[unit]).toFixed(unit === "g" ? 0 : unit === "kg" ? 2 : 1)
|
||||
: "";
|
||||
```
|
||||
|
||||
**Unit selector placement:** In the TotalsBar component. The user sees the unit right where weights are displayed and can switch inline. A small segmented control or dropdown next to the weight display in the top bar.
|
||||
|
||||
---
|
||||
|
||||
## New vs Modified Files -- Complete Inventory
|
||||
|
||||
### New Files (5)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/client/components/SearchBar.tsx` | Reusable search input with clear button |
|
||||
| `src/client/components/WeightChart.tsx` | Donut chart wrapper around react-minimal-pie-chart |
|
||||
| `src/client/components/UnitSelector.tsx` | Weight unit segmented control / dropdown |
|
||||
| `src/client/hooks/useFormatWeight.ts` | Hook combining weight unit setting + formatter |
|
||||
| `src/db/migrations/XXXX_v1.2_columns.sql` | Drizzle migration for new columns |
|
||||
|
||||
### Modified Files (15)
|
||||
|
||||
| File | What Changes |
|
||||
|------|-------------|
|
||||
| `package.json` | Add `react-minimal-pie-chart` dependency |
|
||||
| `src/db/schema.ts` | Add `weightClass` to setup_items, `status` to thread_candidates |
|
||||
| `src/shared/schemas.ts` | Add `status` to candidate schemas, update sync schema |
|
||||
| `src/server/services/setup.service.ts` | Return `weightClass`, add `updateSetupItemClass`, update `syncSetupItems` |
|
||||
| `src/server/services/thread.service.ts` | Include `status` in candidate create/update |
|
||||
| `src/server/routes/setups.ts` | Add `PATCH /:id/items/:itemId` for classification |
|
||||
| `src/client/lib/formatters.ts` | `formatWeight` accepts unit param, add conversion constants |
|
||||
| `src/client/hooks/useSetups.ts` | `SetupItemWithCategory` adds `weightClass`, update sync mutation, add classification mutation |
|
||||
| `src/client/hooks/useThreads.ts` | `CandidateWithCategory` adds `status` field |
|
||||
| `src/client/hooks/useSettings.ts` | Add `useWeightUnit` convenience export |
|
||||
| `src/client/routes/collection/index.tsx` | Add SearchBar + category filter to gear tab, add weight chart |
|
||||
| `src/client/routes/setups/$setupId.tsx` | Classification toggles per item, classification chart, updated totals |
|
||||
| `src/client/components/ItemCard.tsx` | Optional `weightClass` badge in setup context |
|
||||
| `src/client/components/CandidateCard.tsx` | Status badge + click-to-cycle behavior |
|
||||
| `tests/helpers/db.ts` | Add `weight_class` and `status` columns to CREATE TABLE statements |
|
||||
|
||||
### Unchanged Files
|
||||
|
||||
| File | Why No Change |
|
||||
|------|-------------|
|
||||
| `src/client/lib/api.ts` | Existing fetch wrappers handle all new API shapes |
|
||||
| `src/client/stores/uiStore.ts` | No new panel/dialog state needed |
|
||||
| `src/server/routes/items.ts` | Search is client-side |
|
||||
| `src/server/services/item.service.ts` | No query changes needed |
|
||||
| `src/server/services/totals.service.ts` | Category totals unchanged; classification totals computed client-side |
|
||||
| `src/server/routes/totals.ts` | No new endpoints |
|
||||
| `src/server/index.ts` | No new route registrations (setups routes already registered) |
|
||||
|
||||
## Build Order (Dependency-Aware)
|
||||
|
||||
The features have specific dependencies that dictate build order.
|
||||
|
||||
```
|
||||
Phase 1: Weight Unit Selection
|
||||
+-- Modifies formatWeight which is used everywhere
|
||||
+-- Must be done first so subsequent weight displays use the new formatter
|
||||
+-- Dependencies: none (uses existing settings infrastructure)
|
||||
|
||||
Phase 2: Search/Filter
|
||||
+-- Pure client-side addition, no schema changes
|
||||
+-- Can be built independently
|
||||
+-- Dependencies: none
|
||||
|
||||
Phase 3: Candidate Status Tracking
|
||||
+-- Schema migration (simple column add)
|
||||
+-- Minimal integration surface
|
||||
+-- Dependencies: none (but batch schema migration with Phase 4)
|
||||
|
||||
Phase 4: Weight Classification
|
||||
+-- Schema migration + sync API change + new PATCH endpoint
|
||||
+-- Requires weight unit work to be done (displays classification totals)
|
||||
+-- Dependencies: Phase 1 (weight formatting)
|
||||
|
||||
Phase 5: Weight Distribution Charts
|
||||
+-- Depends on weight classification (for setup breakdown chart)
|
||||
+-- Depends on weight unit (chart labels need formatted weights)
|
||||
+-- Dependencies: Phase 1 + Phase 4
|
||||
+-- npm dependency: react-minimal-pie-chart
|
||||
```
|
||||
|
||||
**Batch Phase 3 and Phase 4 schema migrations into one Drizzle migration** since they both add columns. Run `bun run db:generate` once after both schema changes are made.
|
||||
|
||||
## Data Flow Changes Summary
|
||||
|
||||
### Current Data Flows (unchanged)
|
||||
|
||||
```
|
||||
useItems() -> GET /api/items -> getAllItems(db) -> items JOIN categories
|
||||
useThreads() -> GET /api/threads -> getAllThreads(db) -> threads JOIN categories
|
||||
useSetups() -> GET /api/setups -> getAllSetups(db) -> setups + subqueries
|
||||
useTotals() -> GET /api/totals -> getCategoryTotals -> items GROUP BY categoryId
|
||||
```
|
||||
|
||||
### New/Modified Data Flows
|
||||
|
||||
```
|
||||
Search/Filter:
|
||||
CollectionView local state (searchTerm, categoryFilter)
|
||||
-> useMemo over useItems().data
|
||||
-> no API change
|
||||
|
||||
Weight Unit:
|
||||
useFormatWeight() -> useSetting("weightUnit") -> GET /api/settings/weightUnit
|
||||
-> formatWeight(grams, unit) -> display string
|
||||
|
||||
Candidate Status:
|
||||
CandidateCard click -> useUpdateCandidate({ status: "ordered" })
|
||||
-> PUT /api/threads/:id/candidates/:cid -> updateCandidate(db, cid, { status })
|
||||
|
||||
Weight Classification:
|
||||
Setup detail -> getSetupWithItems now returns weightClass per item
|
||||
-> client groups by weightClass for totals
|
||||
-> PATCH /api/setups/:id/items/:itemId updates classification
|
||||
|
||||
Weight Chart:
|
||||
Collection: useTotals().data.categories -> WeightChart segments
|
||||
Setup: setup.items grouped by weightClass -> WeightChart segments
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Anti-Pattern 1: Server-Side Search for Small Collections
|
||||
|
||||
**What people do:** Build a search API with pagination, debounced requests, loading states
|
||||
**Why it's wrong for this app:** Single-user app with <1000 items. Server round-trips add latency and complexity for zero benefit. Client already has all items in React Query cache.
|
||||
**Do this instead:** Filter in-memory using `useMemo` over the cached items array.
|
||||
|
||||
### Anti-Pattern 2: Weight Classification on the Items Table
|
||||
|
||||
**What people do:** Add `weightClass` column to `items` table
|
||||
**Why it's wrong:** An item's classification is context-dependent -- the same item can be "base" in one setup and not present in another. Putting it on `items` forces a single global classification.
|
||||
**Do this instead:** Put `weightClass` on `setup_items` join table. This is how LighterPack and every serious gear tracker works.
|
||||
|
||||
### Anti-Pattern 3: Converting Stored Values to User's Unit
|
||||
|
||||
**What people do:** Store weights in the user's preferred unit, or convert on the server before sending
|
||||
**Why it's wrong:** Changing the unit preference would require re-interpreting all stored data. Different users (future multi-user) might prefer different units from the same data.
|
||||
**Do this instead:** Always store grams in the database. Convert to display unit only in the client formatter. The conversion is a pure function with no side effects.
|
||||
|
||||
### Anti-Pattern 4: Heavy Charting Library for One Chart Type
|
||||
|
||||
**What people do:** Install Recharts (~45kB) or Chart.js (~67kB) for a single donut chart
|
||||
**Why it's wrong:** Massive bundle size overhead for minimal usage. These libraries are designed for dashboards with many chart types.
|
||||
**Do this instead:** Use `react-minimal-pie-chart` (<3kB) which does exactly donut/pie charts with zero dependencies.
|
||||
|
||||
### Anti-Pattern 5: React Context Provider for Weight Unit
|
||||
|
||||
**What people do:** Build a full React Context provider with `createContext`, `useContext`, a Provider wrapper component
|
||||
**Why it's excessive here:** The `useSetting("weightUnit")` hook already provides reactive data through React Query. Adding a Context layer on top adds indirection for no benefit.
|
||||
**Do this instead:** Create a simple custom hook `useFormatWeight()` that internally calls `useSetting("weightUnit")`. React Query already handles caching and reactivity.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Bun Fullstack Dev Server docs](https://bun.com/docs/bundler/fullstack) -- Official documentation on Bun's HTML-based routing and asset bundling
|
||||
- [bun:sqlite API Reference](https://bun.com/reference/bun/sqlite) -- Native SQLite module documentation
|
||||
- [Building Full-Stack App with Bun.js, React and Drizzle ORM](https://awplife.com/building-full-stack-app-with-bun-js-react-drizzle/) -- Project structure reference
|
||||
- [Bun v3.1 Release (InfoQ)](https://www.infoq.com/news/2026/01/bun-v3-1-release/) -- Zero-config frontend, built-in DB clients
|
||||
- [Bun + React + Hono pattern](https://dev.to/falconz/serving-a-react-app-and-hono-api-together-with-bun-1gfg) -- Alternative fullstack patterns
|
||||
- [Inventory Management DB Design (Medium)](https://medium.com/@bhargavkoya56/weekly-db-project-1-inventory-management-db-design-seed-from-schema-design-to-performance-8e6b56445fe6) -- Schema design patterns for inventory systems
|
||||
- [Drizzle ORM Filters Documentation](https://orm.drizzle.team/docs/operators) -- like, and, or operators for SQLite
|
||||
- [Drizzle ORM Conditional Filters Guide](https://orm.drizzle.team/docs/guides/conditional-filters-in-query) -- dynamic filter patterns
|
||||
- [SQLite LIKE case sensitivity with Drizzle](https://github.com/drizzle-team/drizzle-orm-docs/issues/239) -- SQLite LIKE is case-insensitive for ASCII
|
||||
- [react-minimal-pie-chart npm](https://www.npmjs.com/package/react-minimal-pie-chart) -- lightweight pie/donut chart, <3kB gzipped
|
||||
- [react-minimal-pie-chart GitHub](https://github.com/toomuchdesign/react-minimal-pie-chart) -- API docs and examples
|
||||
- [LighterPack Tutorial - 99Boulders](https://www.99boulders.com/lighterpack-tutorial) -- base/worn/consumable weight classification standard
|
||||
- [Pack Weight Categories](https://hikertimes.com/difference-between-base-weight-and-total-weight/) -- base weight vs total weight definitions
|
||||
- [Pack Weight Calculator](https://backpackpeek.com/blog/pack-weight-calculator-base-weight-guide) -- weight classification guide
|
||||
|
||||
---
|
||||
*Architecture research for: GearBox gear management app*
|
||||
*Researched: 2026-03-14*
|
||||
*Architecture research for: GearBox v1.2 Collection Power-Ups*
|
||||
*Researched: 2026-03-16*
|
||||
|
||||
Reference in New Issue
Block a user