31 KiB
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:
// 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:
// 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:
// 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();
}
// 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:
// 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:
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:
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:
// 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:
-
Collection page -- weight by category. Data source:
useTotals().data.categories. EachCategoryTotalsalready hastotalWeightandcategoryName. Assign a consistent color per category (use category index mapped to a palette array). -
Setup detail page -- weight by classification. Data source: computed from
setup.itemsgrouping byweightClass. Three fixed colors for base/worn/consumable.
Color palette for categories:
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):
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:
// 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):
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().
// 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.
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:
// In useSettings.ts
export function useWeightUnit() {
return useSetting("weightUnit"); // Returns "g" | "oz" | "lb" | "kg" or null (default to "g")
}
Conversion constants:
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:
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.
// 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.
// 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:
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 -- like, and, or operators for SQLite
- Drizzle ORM Conditional Filters Guide -- dynamic filter patterns
- SQLite LIKE case sensitivity with Drizzle -- SQLite LIKE is case-insensitive for ASCII
- react-minimal-pie-chart npm -- lightweight pie/donut chart, <3kB gzipped
- react-minimal-pie-chart GitHub -- API docs and examples
- LighterPack Tutorial - 99Boulders -- base/worn/consumable weight classification standard
- Pack Weight Categories -- base weight vs total weight definitions
- Pack Weight Calculator -- weight classification guide
Architecture research for: GearBox v1.2 Collection Power-Ups Researched: 2026-03-16