Files

26 KiB

Phase 23: Manual Entry Fallback - Research

Researched: 2026-04-06 Domain: React UI — inline form view state within CatalogSearchOverlay Confidence: HIGH

Summary

Phase 23 is a pure frontend phase. The goal is to give users a way to add items that do not exist in the global catalog by surfacing a manual entry form inline inside the CatalogSearchOverlay. The backend is already fully capable — POST /api/items without a globalItemId creates a standalone collection item, and createItemSchema accepts the call without that field. Nothing on the server needs to change.

The implementation has two distinct layers. First, the entry points: an "Add Manually" link in the EmptyState component and a persistent but subtle link below the results area. Second, the inline form view: a new ManualEntryForm component rendered inside CatalogSearchOverlay when manualEntryMode state is true. The form reuses established patterns from ItemForm and AddToCollectionModal but is lighter and more focused. After a successful save, the results area is replaced by a success card with a non-functional "Submit to Catalog?" button and "Add Another" / "Done" actions.

There is no new route, no new API endpoint, and no Zustand store change required. All view-switching state is local to CatalogSearchOverlay via useState. This is the simplest possible scope for the phase.

Primary recommendation: Implement the entire phase as local state inside CatalogSearchOverlay — manualEntryMode: boolean and savedItemName: string | null — with a new sibling component file ManualEntryForm.tsx.


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: "Add Manually" link appears in the catalog search empty state (when no results match) AND as a subtle persistent link below search results.
  • D-02: Link text is "Add Manually" or "Can't find it? Add manually" — context-sensitive based on whether a search query exists.
  • D-03: Manual entry form replaces the search results area inline within CatalogSearchOverlay. No navigation away or additional modal.
  • D-04: Back arrow at the top returns from the manual form to search results.
  • D-05: The form is a dedicated ManualEntryForm component rendered inside CatalogSearchOverlay when manualEntryMode state is active.
  • D-06: Required: name. Optional: category (via CategoryPicker), weight (grams), price (cents), notes, purchase price, image upload, product link.
  • D-07: Reuse field patterns from ItemForm (CategoryPicker, weight/price inputs) but keep the form focused and compact — not the full 315-line ItemForm.
  • D-08: Submit calls POST /api/items without globalItemId — creates a standalone collection item.
  • D-09: After successful save, show an inline success card: "Added [item name] to collection" with a "Submit to Catalog?" button.
  • D-10: "Submit to Catalog?" button is non-functional — shows a toast "Coming soon" when clicked. No backend action.
  • D-11: Below the prompt, show "Add Another" (returns to search) and "Done" (closes overlay) buttons.

Claude's Discretion

  • Exact form layout proportions and field ordering
  • Whether weight/price fields use formatted inputs or plain number inputs
  • Animation transitions between search results and manual entry form
  • Success card visual styling
  • Whether the search query auto-populates the item name field when entering manual mode

Deferred Ideas (OUT OF SCOPE)

  • Actual catalog submission backend (admin review, convert to global item) — future phase
  • Bulk manual entry — future phase
  • Image search / URL paste to auto-populate item details — future phase </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
CATFLOW-07 Manual entry fallback when item not in catalog Implemented via manualEntryMode local state in CatalogSearchOverlay + ManualEntryForm component; useCreateItem() hook + POST /api/items without globalItemId already supported
CATFLOW-08 Non-functional "Submit to catalog?" prompt shown after manual save Implemented as an inline success card within CatalogSearchOverlay after mutation success; "Submit to Catalog?" button calls toast("Coming soon")
</phase_requirements>

Standard Stack

Core

Library Version Purpose Why Standard
React 19 (project) Component rendering and local state Project stack
TanStack React Query project version useCreateItem() mutation Established data layer
Framer Motion project version Animation between search/form views Already used in CatalogSearchOverlay
sonner toast project version Success/error/coming-soon notifications Established project pattern
Zustand project version Overlay open/close state (no change needed) Established UI state pattern

Supporting

Library Version Purpose When to Use
CategoryPicker internal Category selection with search + inline create Required by D-06
ImageUpload internal Image upload with preview Required by D-06

Alternatives Considered

Instead of Could Use Tradeoff
Local useState for mode switching Zustand UIStore Local state is simpler; no cross-component coordination needed; CONTEXT.md D-05 implies local state

Installation: No new packages needed.

Architecture Patterns

No new directories. Two new files:

src/client/components/
├── CatalogSearchOverlay.tsx    # Modified — add mode state, entry points, conditional rendering
└── ManualEntryForm.tsx         # New — standalone form component for manual item entry

Pattern 1: Inline View Switching via Local State

What: CatalogSearchOverlay already uses local useState for viewMode, filterOpen, searchInput, etc. The same pattern extends naturally to manualEntryMode.

When to use: When an overlay needs to transition between views without navigating or spawning a new modal layer.

Example:

// Inside CatalogSearchOverlay
const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null);

// Reset when overlay closes (add to existing reset effect)
useEffect(() => {
  if (!catalogSearchOpen) {
    setManualEntryMode(false);
    setSavedItemName(null);
    // ... existing resets
  }
}, [catalogSearchOpen]);

// Render switch
{manualEntryMode
  ? savedItemName
    ? <SuccessCard itemName={savedItemName} onAddAnother={...} onDone={...} />
    : <ManualEntryForm initialName={searchInput} onSuccess={...} onBack={...} />
  : <ResultsArea ... />
}

Source: CatalogSearchOverlay.tsx existing local state pattern (verified by reading the file).

Pattern 2: Compact Form Adapted from ItemForm

What: ManualEntryForm mirrors ItemForm's field patterns (validation, CategoryPicker, weight/price conversion) but is a lighter self-contained component — not tied to UIStore.

Key adaptation from ItemForm:

  • No mode: "add" | "edit" — always add-only
  • No useItems() call — no need for pre-fill
  • No delete button
  • Accepts initialName prop (auto-populated from search query per CONTEXT.md specifics)
  • Accepts onSuccess(itemName: string) and onBack() callbacks
  • purchasePriceCents field (from AddToCollectionModal) added alongside the regular priceCents field

Example component signature:

interface ManualEntryFormProps {
  initialName?: string;
  onSuccess: (itemName: string) => void;
  onBack: () => void;
}

Source: Verified by reading src/client/components/ItemForm.tsx and src/client/components/AddToCollectionModal.tsx.

Pattern 3: useCreateItem without globalItemId

What: The existing useCreateItem() mutation accepts CreateItem from src/shared/types. The createItemSchema in src/shared/schemas.ts defines globalItemId as .optional(), so omitting it is fully valid.

Example:

createItem.mutate({
  name: form.name.trim(),
  categoryId: form.categoryId,
  weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
  priceCents: form.priceDollars ? Math.round(Number(form.priceDollars) * 100) : undefined,
  notes: form.notes || undefined,
  productUrl: form.productUrl || undefined,
  imageFilename: form.imageFilename ?? undefined,
  purchasePriceCents: form.purchasePrice ? Math.round(Number(form.purchasePrice) * 100) : undefined,
  // globalItemId deliberately omitted
}, {
  onSuccess: (item) => onSuccess(item.name),
  onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
});

Source: src/shared/schemas.ts line 13 (globalItemId: z.number().int().positive().optional()), src/client/hooks/useItems.ts lines 62-72.

Pattern 4: "Add Manually" Entry Points

What: Two placements per D-01 — inside EmptyState and as a persistent link below results.

EmptyState adaptation (line 667 in CatalogSearchOverlay.tsx):

function EmptyState({ hasQuery, onAddManually }: {
  hasQuery: boolean;
  onAddManually: () => void;
}) {
  return (
    <div className="flex flex-col items-center justify-center py-20 px-4">
      {/* existing icon + text */}
      <button
        type="button"
        onClick={onAddManually}
        className="mt-4 text-sm text-blue-600 hover:text-blue-800 underline-offset-2 hover:underline"
      >
        {hasQuery ? "Can't find it? Add manually" : "Add Manually"}
      </button>
    </div>
  );
}

Persistent link below results: A div rendered after the results grid/list, always visible when not in manual entry mode.

Pattern 5: Toast for Non-functional Button

What: The "Submit to Catalog?" button (D-10) calls toast() from sonner — no backend call.

import { toast } from "sonner";
// ...
<button type="button" onClick={() => toast("Coming soon — catalog submissions are on the roadmap!")}>
  Submit to Catalog?
</button>

Source: toast from sonner is the established project notification mechanism (confirmed in AddToCollectionModal.tsx and CatalogSearchOverlay usage patterns).

Anti-Patterns to Avoid

  • Adding manualEntryMode to Zustand UIStore: Unnecessary. No other component needs to read this state. CONTEXT.md D-05 says it's local to CatalogSearchOverlay.
  • Reusing ItemForm directly: ItemForm is 315 lines, tied to UIStore, and has edit/delete logic not needed here. Adapt the patterns, build a fresh focused component.
  • Making the form a full-screen overlay on top of the overlay: The overlay stays. The form replaces the results area content only (D-03).
  • Emitting a success toast AND showing the success card: Don't double-notify. The success card IS the success feedback. Skip the toast.success() call here (unlike AddToCollectionModal which just closes and toasts).

Don't Hand-Roll

Problem Don't Build Use Instead Why
Category selection Custom select CategoryPicker Has search, inline create, icon display
Image upload Custom file input ImageUpload Handles upload API call, preview, size validation
Price/weight conversion Custom logic Adapt from ItemForm Established pattern: Math.round(Number(dollars) * 100)
Toast notifications Custom UI toast from sonner Global notification system already wired
Item creation API call Custom fetch useCreateItem() Handles query invalidation, error states

Key insight: This phase is almost entirely wiring — the building blocks (mutation hook, form components, toast) exist. The work is composing them into a new view state inside CatalogSearchOverlay.

Common Pitfalls

Pitfall 1: Forgetting to Reset manualEntryMode on Overlay Close

What goes wrong: User closes overlay, reopens it, and the manual entry form is still showing instead of search results.

Why it happens: The reset useEffect (lines 76-87 in CatalogSearchOverlay) clears search/filter state when catalogSearchOpen changes to false — but new state like manualEntryMode and savedItemName won't be in that effect unless explicitly added.

How to avoid: Add setManualEntryMode(false) and setSavedItemName(null) to the existing reset effect that triggers on !catalogSearchOpen.

Warning signs: State persists across overlay open/close cycles during testing.

Pitfall 2: "Submit to Catalog?" Button Appearing Functional

What goes wrong: A loading spinner, disabled state, or API call is added to the "Submit to Catalog?" button, setting a false expectation.

Why it happens: Developer instinct to make buttons "do something properly."

How to avoid: The button calls toast("Coming soon") and nothing else (D-10). No isPending, no disabled, no API call.

Pitfall 3: Back Arrow Wiring

What goes wrong: Back arrow on ManualEntryForm calls closeCatalogSearch() instead of returning to search results.

Why it happens: CatalogSearchOverlay already has a back arrow in the header that closes the overlay. ManualEntryForm needs its own back behavior that sets manualEntryMode(false).

How to avoid: The ManualEntryForm header renders its own back arrow button that calls the onBack prop — setManualEntryMode(false). Alternatively, CatalogSearchOverlay can make its existing header back arrow context-sensitive: when manualEntryMode, go back to results; otherwise close overlay.

Warning signs: Pressing back from the manual form closes the entire overlay instead of returning to search.

Pitfall 4: categoryId Validation

What goes wrong: CategoryPicker is initialized with value={0} which doesn't correspond to a real category, causing categoryId: 0 to be submitted — this will fail the Zod schema (z.number().int().positive()).

Why it happens: Same issue exists in AddToCollectionModal (solved by pre-selecting categories[0].id once data loads).

How to avoid: Follow the AddToCollectionModal pattern — initialize categoryId as null, pre-select categories[0].id once categories data loads, block form submission if categoryId === null.

Pitfall 5: Search Query Not Auto-Populating Item Name

What goes wrong: User types "Ortlieb Gravel Pack" in the search, gets no results, clicks "Add Manually" — the name field is empty. User must retype the name.

Why it happens: searchInput is local state in CatalogSearchOverlay and ManualEntryForm doesn't receive it.

How to avoid: Pass searchInput (or debouncedQuery) as the initialName prop to ManualEntryForm. The CONTEXT.md specifics section explicitly calls this out. This is a Claude's Discretion item but the CONTEXT.md says it "should" happen.

Code Examples

ManualEntryForm: Minimal Skeleton

// Source: adapted from src/client/components/ItemForm.tsx + AddToCollectionModal.tsx
import { useEffect, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { useCategories } from "../hooks/useCategories";
import { useCreateItem } from "../hooks/useItems";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";

interface ManualEntryFormProps {
  initialName?: string;
  onSuccess: (itemName: string) => void;
  onBack: () => void;
}

export function ManualEntryForm({ initialName, onSuccess, onBack }: ManualEntryFormProps) {
  const { data: categories } = useCategories();
  const createItem = useCreateItem();

  const [name, setName] = useState(initialName ?? "");
  const [categoryId, setCategoryId] = useState<number | null>(null);
  const [weightGrams, setWeightGrams] = useState("");
  const [priceDollars, setPriceDollars] = useState("");
  const [purchasePrice, setPurchasePrice] = useState("");
  const [notes, setNotes] = useState("");
  const [productUrl, setProductUrl] = useState("");
  const [imageFilename, setImageFilename] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (categories && categories.length > 0 && categoryId === null) {
      setCategoryId(categories[0].id);
    }
  }, [categories, categoryId]);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!name.trim()) { setError("Name is required"); return; }
    if (categoryId === null) { setError("Select a category"); return; }
    setError(null);

    createItem.mutate({
      name: name.trim(),
      categoryId,
      weightGrams: weightGrams ? Number(weightGrams) : undefined,
      priceCents: priceDollars ? Math.round(Number(priceDollars) * 100) : undefined,
      purchasePriceCents: purchasePrice ? Math.round(Number(purchasePrice) * 100) : undefined,
      notes: notes || undefined,
      productUrl: productUrl || undefined,
      imageFilename: imageFilename ?? undefined,
      // globalItemId omitted — standalone item
    }, {
      onSuccess: (item) => onSuccess(item.name),
      onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
    });
  }

  // ... render
}

CatalogSearchOverlay: Mode Switch Wiring

// Source: pattern from CatalogSearchOverlay.tsx local state
const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null);

// In existing reset effect:
useEffect(() => {
  if (!catalogSearchOpen) {
    // ...existing resets...
    setManualEntryMode(false);
    setSavedItemName(null);
  }
}, [catalogSearchOpen]);

function handleEnterManualMode() {
  setManualEntryMode(true);
}

function handleManualSuccess(itemName: string) {
  setSavedItemName(itemName);
}

function handleAddAnother() {
  setManualEntryMode(false);
  setSavedItemName(null);
}
// Source: adapted from CatalogSearchOverlay.tsx EmptyState (line 667)
function EmptyState({ hasQuery, onAddManually }: {
  hasQuery: boolean;
  onAddManually: () => void;
}) {
  return (
    <div className="flex flex-col items-center justify-center py-20 px-4">
      {/* existing SVG icon */}
      <p className="text-sm text-gray-500 text-center mb-3">
        {hasQuery ? "No items found matching your search" : "Search the catalog to find gear"}
      </p>
      <button
        type="button"
        onClick={onAddManually}
        className="text-sm text-gray-500 hover:text-gray-700 underline underline-offset-2"
      >
        {hasQuery ? "Can't find it? Add manually" : "Add Manually"}
      </button>
    </div>
  );
}

State of the Art

Old Approach Current Approach When Changed Impact
Static EmptyState (no action) EmptyState with "Add Manually" CTA Phase 23 Removes dead end for missing catalog items
No manual path in catalog flow Inline ManualEntryForm in overlay Phase 23 Completes CATFLOW requirements

What changes in Phase 23:

  • EmptyState — accepts onAddManually prop, gains a text button
  • CatalogSearchOverlay — gains manualEntryMode + savedItemName local state, renders ManualEntryForm or SuccessCard conditionally
  • New file: ManualEntryForm.tsx

Open Questions

  1. Back arrow context-sensitivity

    • What we know: CatalogSearchOverlay header already has a back arrow that calls closeCatalogSearch(). ManualEntryForm needs to go back to results, not close the overlay (D-04).
    • What's unclear: Whether the planner should (a) make the header back arrow context-aware based on manualEntryMode, or (b) have ManualEntryForm render its own internal back arrow in the form header and keep the outer header arrow always closing.
    • Recommendation: Option (b) — ManualEntryForm has its own back arrow row (like CatalogSearchOverlay's context text row) to preserve single-responsibility. The planner should specify this explicitly.
  2. "Add Manually" link in thread mode

    • What we know: catalogSearchMode can be "collection" or "thread". The CONTEXT.md does not restrict manual entry to collection mode only.
    • What's unclear: If the user is in "thread" mode (adding candidates), does "Add Manually" create a standalone collection item (D-08) or a thread candidate?
    • Recommendation: D-08 locks this to POST /api/items (collection item), regardless of mode. The planner should note this explicitly to avoid an implementation where thread mode triggers candidate creation instead.

Environment Availability

Step 2.6: SKIPPED — phase is purely frontend code changes with no external tool or service dependencies beyond the existing dev environment.

Validation Architecture

Test Framework

Property Value
Framework Bun test (unit/integration) + Playwright (E2E)
Config file bunfig.toml (Bun default), playwright.config.ts
Quick run command bun test tests/services/item.service.test.ts
Full suite command bun test && bun run test:e2e

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
CATFLOW-07 ManualEntryForm submits POST /api/items without globalItemId Integration (service) bun test tests/services/item.service.test.ts Partial — item creation without globalItemId covered by existing "only name and categoryId are required" test
CATFLOW-07 "Add Manually" link visible in EmptyState E2E (smoke) bun run test:e2e (collection.spec.ts) Needs Wave 0 addition
CATFLOW-07 ManualEntryForm renders in overlay on click E2E bun run test:e2e Needs Wave 0 addition
CATFLOW-07 Back arrow returns to search results E2E bun run test:e2e Needs Wave 0 addition
CATFLOW-08 "Submit to Catalog?" shows "Coming soon" toast E2E bun run test:e2e Needs Wave 0 addition
CATFLOW-08 Success card shown after manual save E2E bun run test:e2e Needs Wave 0 addition

Note: The backend createItem service already has tests covering the standalone item path (no globalItemId). The existing test "only name and categoryId are required" (item.service.test.ts line 44) covers CATFLOW-07 at the service layer. The gaps are E2E tests covering the new UI flow.

Sampling Rate

  • Per task commit: bun test tests/services/item.service.test.ts
  • Per wave merge: bun test
  • Phase gate: bun test && bun run test:e2e green before /gsd:verify-work

Wave 0 Gaps

  • e2e/collection.spec.ts — add test cases for manual entry flow (Add Manually link, ManualEntryForm render, back navigation, success card, Submit to Catalog toast)

(Service-level test infrastructure covers the backend path — no new test files needed there. E2E additions only.)

Project Constraints (from CLAUDE.md)

All directives from CLAUDE.md that apply to this phase:

Constraint Impact on Phase 23
Reuse existing components (CategoryPicker, ImageUpload, etc.) ManualEntryForm MUST use CategoryPicker and ImageUpload — not plain <select> or raw file inputs
Prices stored as cents (priceCents: integer) Convert dollars → cents with Math.round(val * 100) before sending to API
Path alias @/* maps to ./src/* Use @/client/... imports in new component
TanStack Router file-based routes — routeTree.gen.ts never edited manually No new routes in this phase (none needed)
Tailwind CSS v4 Use Tailwind for all styling — no inline styles
toast() from sonner for notifications "Coming soon" and error messages use sonner toast
Bun test runner (bun test) Tests written as Bun tests, not Jest/Vitest
Tabs, double quotes (Biome lint) Follow formatting — run bun run lint before commit
UIStore for panel/dialog state only; server data lives in React Query manualEntryMode and savedItemName are local state, not UIStore — correct by design

Sources

Primary (HIGH confidence)

  • src/client/components/CatalogSearchOverlay.tsx — Full file read; confirmed local state pattern, EmptyState location (line 667), existing reset effect, framer-motion usage
  • src/client/components/ItemForm.tsx — Full file read; confirmed field patterns, validation logic, CategoryPicker/ImageUpload usage
  • src/client/components/AddToCollectionModal.tsx — Full file read; confirmed lightweight form pattern, purchasePriceCents field, toast.success pattern
  • src/client/hooks/useItems.ts — Full file read; confirmed useCreateItem() signature and query invalidation
  • src/shared/schemas.ts — Full file read; confirmed globalItemId: z.number().int().positive().optional() (line 13)
  • src/client/stores/uiStore.ts — Full file read; confirmed overlay open/close state, no manualEntryMode currently
  • .planning/phases/23-manual-entry-fallback/23-CONTEXT.md — Full read; all locked decisions documented
  • tests/services/item.service.test.ts — Partial read; confirmed existing service test coverage for standalone item creation

Secondary (MEDIUM confidence)

  • N/A — this phase is entirely within the existing codebase with no external library research needed

Tertiary (LOW confidence)

  • N/A

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all libraries are already in the project, versions verified by reading source files
  • Architecture: HIGH — patterns read directly from existing components; no speculation
  • Pitfalls: HIGH — derived from reading actual code (reset effect, CategoryPicker value=0 issue in AddToCollectionModal)

Research date: 2026-04-06 Valid until: 2026-07-06 (stable — no fast-moving external dependencies)