Files
GearBox/.planning/research/ARCHITECTURE.md

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:

  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:

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


Architecture research for: GearBox v1.2 Collection Power-Ups Researched: 2026-03-16