Files
electronics-inventory/CLAUDE.md
mikael-lovqvists-claude-agent 72897c5b2d Add CLAUDE.md — agent orientation file
Covers: file map, KV prefix table, all data model shapes, full API
route index, frontend section layout, image file lifecycle, code style
preferences, git conventions, and a pointer to future-plans.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:18:12 +00:00

14 KiB
Raw Blame History

CLAUDE.md — Electronics Inventory

Agent orientation file. Read this before touching code.


What this project is

A self-hosted electronics inventory web app. Tracks components, PDFs/datasheets, physical storage grids (photographed and de-perspectived), and bins. Single-user, no auth. Node.js + Express 5 backend, vanilla JS SPA frontend, flat NDJSON key-value store for persistence.


File map

server.mjs                   Entry point. All Express routes. ~940 lines.
lib/
  storage.mjs                KV store CRUD wrappers (one section per entity type).
                             Read this to understand what data exists and its prefix.
  kv-store.mjs               Flat NDJSON key-value store (Simple_KeyValue_Store class).
                             Auto-loads on construct, auto-flushes with debounce.
  grid-image.mjs             Image processing: de-perspective a photo into a grid of
                             cell images using sharp. compute_bin_size() lives here.
  ids.mjs                    generate_id() — timestamp-base36 + random suffix.
                             (Planned: migrate to sequential integers.)
public/
  app.mjs                    SPA. All rendering, routing, dialog logic. ~2600 lines.
                             Sections separated by // --- comments.
  lib/api.mjs                All fetch wrappers. Read this for the full API surface.
  lib/dom.mjs                Tiny DOM helpers: qs(), clone(), show(), hide().
  views/grid-setup.mjs       Grid_Setup class — canvas corner editor with pan/zoom.
                             Used for both grid source images and bin corner editing.
  templates.html             All HTML templates (id="t-*"). Injected into body at init.
  style.css                  All styles. Single file (planned: split per section).
  index.html                 Shell. Loads app.mjs as module. Nav buttons hardcoded.
tools/
  mv-sync.c / mv-sync        renameat2(RENAME_NOREPLACE) binary for atomic rename
                             without overwrite. Used by settle_image_filename().
  Makefile                   Builds mv-sync.
data/
  inventory.ndjson           The database. All entities in one flat KV file.
  images/                    All uploaded and processed images.
  pdfs/                      Uploaded PDF files.
  thumbs/                    PDF thumbnails (generated by pdftoppm).

KV store — key prefixes

Prefix Entity Storage function family
f: Field definitions get_field / set_field
c: Components get_component / set_component
i: Inventory entries get_inventory_entry / set_inventory_entry
d: Grid drafts get_grid_draft / set_grid_draft
s: Source images get_source_image / add_source_image
g: Grid images get_grid_image / set_grid_image
ct: Component templates get_component_template / set_component_template
pdf: PDF files get_pdf / set_pdf
bt: Bin types get_bin_type / set_bin_type
bin: Bins get_bin / set_bin

All list_*() functions do a full-scan startsWith(prefix) over the store.


Data model shapes

Field definition (f:)

{
  id: string,           // generate_id()
  name: string,         // e.g. 'resistance'
  unit: string,         // e.g. 'Ω' — optional
  description: string,
  created_at: number,   // ms timestamp
}

Component (c:)

{
  id: string,
  name: string,
  description: string,
  fields: { [field_id]: string },   // values keyed by field definition id
  images: string[],                 // filenames in data/images/
  file_ids: string[],               // linked PDF ids
  created_at: number,
  updated_at: number,
}

Inventory entry (i:)

{
  id: string,
  component_id: string,
  location_type: 'physical' | 'bom' | 'digital' | 'grid',
  location_ref: string,       // free text for physical/bom/digital
  quantity: string,
  notes: string,
  grid_id: string | null,     // set when location_type === 'grid'
  grid_row: number | null,
  grid_col: number | null,
  images: string[],
  created_at: number,
  updated_at: number,
}

Source image (s:)

{
  id: string,             // filename in data/images/ (used as key too)
  original_name: string,
  width: number,
  height: number,
  uses: ('grid' | 'bin')[],   // which features reference this image
  created_at: number,
}

Grid draft (d:)

{
  id: string,
  source_id: string,      // source image filename
  rows: number,
  cols: number,
  corners: [{x,y}, {x,y}, {x,y}, {x,y}],  // TL, TR, BR, BL in image coords
  created_at: number,
  updated_at: number,
}

Grid image (g:) — result of processing a grid draft

{
  id: string,
  source_id: string,
  rows: number,
  cols: number,
  corners: [{x,y}, ...],
  panels: [[{ filename, component_id?, notes? }, ...], ...],  // [row][col]
  created_at: number,
  updated_at: number,
}

Component template (ct:)

{
  id: string,
  name: string,
  formatter: string,    // JS function body string, compiled at runtime
  created_at: number,
  updated_at: number,
}

PDF (pdf:)

{
  id: string,
  display_name: string,
  filename: string,     // in data/pdfs/
  thumb_prefix: string, // in data/thumbs/ — pdftoppm output prefix
  created_at: number,
}

Bin type (bt:)

{
  id: string,
  name: string,
  phys_w: number,       // mm
  phys_h: number,       // mm
  description: string,
  fields: { [field_id]: string },
  created_at: number,
  updated_at: number,
}

Bin (bin:)

{
  id: string,
  name: string,
  type_id: string | null,       // ref to bin type
  source_id: string,            // source image filename (always kept)
  source_w: number,
  source_h: number,
  corners: [{x,y}, {x,y}, {x,y}, {x,y}],  // TL, TR, BR, BL in image coords
  phys_w: number | null,        // mm — null means infer from corners
  phys_h: number | null,
  image_filename: string | null,  // processed output in data/images/; null if not yet processed
  bin_w: number | null,           // px dimensions of processed output
  bin_h: number | null,
  fields: { [field_id]: string },
  contents: [                     // embedded content items
    {
      id: string,
      type: 'component' | 'item',
      component_id: string | null,  // set when type === 'component'
      name: string | null,          // set when type === 'item'
      quantity: string,
      notes: string,
      created_at: number,
    }
  ],
  created_at: number,
  updated_at: number,
}

API routes (server.mjs)

All responses: { ok: true, ...data } or { ok: false, error: string }.

GET    /api/fields
POST   /api/fields                        body: { name, unit?, description? }
PUT    /api/fields/:id
DELETE /api/fields/:id

GET    /api/components
POST   /api/components                    body: { name, description?, fields? }
GET    /api/components/:id
PUT    /api/components/:id                body: { name?, description?, fields?, file_ids? }
DELETE /api/components/:id
POST   /api/components/:id/images         multipart: images[]
DELETE /api/components/:id/images/:img_id

GET    /api/inventory
POST   /api/inventory                     body: { component_id, location_type, ... }
PUT    /api/inventory/:id
DELETE /api/inventory/:id
POST   /api/inventory/:id/images          multipart: images[]
DELETE /api/inventory/:id/images/:img_id

GET    /api/grid-drafts
POST   /api/grid-drafts                   body: { source_id, rows, cols }
PUT    /api/grid-drafts/:id
DELETE /api/grid-drafts/:id

GET    /api/source-images
POST   /api/source-images                 multipart: images[] — creates source records (uses: ['grid'])
PUT    /api/source-images/:id             body: { uses }
DELETE /api/source-images/:id             guarded: refused if any grid/bin still references it

GET    /api/grid-images
GET    /api/grid-images/:id
POST   /api/grid-images                   body: { draft_id } — processes draft → grid image
PUT    /api/grid-images/:id/panels/:pi    body: { component_id?, notes? }
DELETE /api/grid-images/:id

GET    /api/component-templates
POST   /api/component-templates           body: { name, formatter }
PUT    /api/component-templates/:id
DELETE /api/component-templates/:id

GET    /api/pdfs
POST   /api/pdfs                          multipart: file, display_name, filename
PUT    /api/pdfs/:id                      body: { display_name, filename }
DELETE /api/pdfs/:id                      guarded: refused if any component references it

GET    /api/bin-types
POST   /api/bin-types                     body: { name, phys_w, phys_h, description?, fields? }
PUT    /api/bin-types/:id
DELETE /api/bin-types/:id                 guarded: refused if any bin references it

GET    /api/bins
GET    /api/bins/:id
POST   /api/bins                          multipart: image, name?, type_id? — upload + create
POST   /api/bins/from-source              body: { source_id, name?, type_id? }
PUT    /api/bins/:id                      body: { name?, type_id?, fields? }
PUT    /api/bins/:id/corners              body: { corners, phys_w?, phys_h? } — triggers reprocess
DELETE /api/bins/:id                      only deletes processed image_filename, not source
POST   /api/bins/:id/contents             body: { type, component_id?, name?, quantity, notes }
PUT    /api/bins/:id/contents/:cid        body: { quantity?, notes?, name? }
DELETE /api/bins/:id/contents/:cid

POST   /api/maintenance/purge-missing-sources   removes source KV entries whose files are gone
POST   /api/maintenance/pdf-thumbs             regenerates missing PDF thumbnails

Frontend (app.mjs) structure

Module-level state variables at the top (all_components, all_fields, etc.). All loaded once at startup via parallel API calls, mutated in place on changes.

Key patterns:

  • clone('t-template-id') — clones a <template> into a live element
  • qs(el, '#id') — scoped querySelector
  • Dialog callbacks stored as module-level let x_dialog_callback = null, set by the open function, called by the init-registered submit/save handler
  • render() — top-level re-render, called after navigation or data changes
  • navigate(path) — pushes history + calls render()
  • build_field_editor(rows_el, sel_el, new_btn_el, initial_fields) — shared helper that wires up field row editing; returns { get_fields() }

Section layout (by line range, approximate):

190      Imports, state vars, startup data load
91250    Helper functions (field rendering, search, formatters)
251700   Components section (list, detail panel, edit dialog)
7011000  Inventory section
10011600 Grids section (list, draft editor, grid viewer)
16011900 Component templates, field definitions dialogs
19012000 Images admin section
20012450 Bins section (list, source list, types list, bin editor)
24502500 Routing (parse_url, navigate, render)
2500end  init() — dialog injection, event handler registration

Image file lifecycle

Upload →  multer writes temp file to data/images/<generate_id()><ext>
       →  settle_image_filename() renames to original filename using
          rename_no_replace (atomic, no overwrite); falls back to temp name
       →  source image KV record created with id = final filename

Grid processing:
  source image + corners + rows/cols → sharp perspective transform
  → one cell image per panel → filenames stored in grid_image.panels[r][c]

Bin processing:
  source image + corners → single de-perspectived image
  → stored as bin.image_filename (separate from bin.source_id)
  → source_id is never deleted when bin is deleted; only image_filename is

Code style (owner preferences)

  • Indentation: tabs, display width 4
  • Braces: always, even single-line bodies — if (x) { return; }
  • Naming: lower_snek_case functions/locals/singletons, CAPITAL_SNEK_CASE top-level constants, Title_Snek_Case classes
  • Quotes: single quotes preferred
  • Modules: .mjs extension always
  • State: stateful components must be classes; no bare module-level variables for app state (module scope is for constants and exports only)
  • IDs: prefer sequential integers over timestamp+random (migration pending)
  • No CDN URLs in HTML; vendor libs via make build from node_modules
  • Error handling: try/catch ENOENT, never existsSync-then-read (TOCTOU)
  • No over-engineering: don't add helpers, abstractions, or error handling for scenarios that can't happen; three similar lines beats a premature helper

Git conventions (this project)

git config user.name  'mikael-lovqvists-claude-agent'
git config user.email 'mikaels.claude.agent@efforting.tech'
  • Small, logical commits — one concern per commit
  • Stage files explicitly by name, never git add -A or git add .
  • Always git status and read every line before staging
  • Commit message: imperative mood, explain why not what

Known limitations / planned rewrite notes

See future-plans.md for full detail. Key points relevant to coding decisions:

  • app.mjs will be split into per-section view modules
  • KV store will become hierarchical (no more prefix convention)
  • IDs will migrate to sequential integers
  • CSS will be split per section with a build-step concatenation
  • SSE for live updates across devices
  • Generic fields on all entity types (already done for components, bins, bin types)
  • A complete rewrite is planned; this codebase is the learning prototype