678 lines
31 KiB
Markdown
678 lines
31 KiB
Markdown
# Architecture Research
|
|
|
|
**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
|
|
|
|
## System Overview: Integration Map
|
|
|
|
The v1.2 features integrate across all existing layers. This diagram shows where new components slot in relative to the current architecture.
|
|
|
|
```
|
|
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) |
|
|
+-----------------------------------------------------------------+
|
|
```
|
|
|
|
## Feature-by-Feature Integration
|
|
|
|
### Feature 1: Search Items and Filter by Category
|
|
|
|
**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.
|
|
|
|
**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:**
|
|
|
|
```
|
|
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
|
|
```
|
|
|
|
**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.
|
|
|
|
**Pattern -- filtered items with useMemo:**
|
|
|
|
```typescript
|
|
// In CollectionView component
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
|
|
|
|
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]);
|
|
```
|
|
|
|
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.
|
|
|
|
**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:**
|
|
|
|
```typescript
|
|
// In schema.ts -- setup_items table
|
|
export const setupItems = sqliteTable("setup_items", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
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"
|
|
});
|
|
```
|
|
|
|
**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.
|
|
|
|
**New endpoint for classification update:**
|
|
|
|
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();
|
|
}
|
|
```
|
|
|
|
```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 });
|
|
});
|
|
```
|
|
|
|
**Also update syncSetupItems** to preserve existing classifications or accept them:
|
|
|
|
```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();
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
**Sync schema update:**
|
|
|
|
```typescript
|
|
export const syncSetupItemsSchema = z.object({
|
|
items: z.array(z.object({
|
|
itemId: z.number().int().positive(),
|
|
weightClass: z.enum(["base", "worn", "consumable"]).default("base"),
|
|
})),
|
|
});
|
|
```
|
|
|
|
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`.
|
|
|
|
**Client-side classification totals** are computed from the setup items array, not from a separate API:
|
|
|
|
```typescript
|
|
const baseWeight = setup.items
|
|
.filter(i => i.weightClass === "base")
|
|
.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
|
|
|
const wornWeight = setup.items
|
|
.filter(i => i.weightClass === "worn")
|
|
.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
|
|
|
const consumableWeight = setup.items
|
|
.filter(i => i.weightClass === "consumable")
|
|
.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
|
|
```
|
|
|
|
**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.
|
|
|
|
---
|
|
|
|
### Feature 3: Weight Distribution Visualization
|
|
|
|
**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.
|
|
|
|
**Integration points:**
|
|
|
|
| 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 |
|
|
|
|
**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.
|
|
|
|
**Chart component pattern:**
|
|
|
|
```typescript
|
|
// components/WeightChart.tsx
|
|
import { PieChart } from "react-minimal-pie-chart";
|
|
|
|
interface WeightChartProps {
|
|
segments: Array<{
|
|
label: string;
|
|
value: number; // weight in grams (always grams internally)
|
|
color: string;
|
|
}>;
|
|
size?: number;
|
|
}
|
|
|
|
export function WeightChart({ segments, size = 200 }: WeightChartProps) {
|
|
const filtered = segments.filter(s => s.value > 0);
|
|
if (filtered.length === 0) return null;
|
|
|
|
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 }}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Two usage contexts:**
|
|
|
|
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).
|
|
|
|
2. **Setup detail page** -- weight by classification. Data source: computed from `setup.items` grouping by `weightClass`. Three fixed colors for base/worn/consumable.
|
|
|
|
**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
|
|
|
|
- [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 v1.2 Collection Power-Ups*
|
|
*Researched: 2026-03-16*
|