diff --git a/CLAUDE.md b/CLAUDE.md
index 89c307d15..5ae43bfb5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,3 +74,5 @@ npm install && npm run dev
- Status: green/blue/yellow/red
- Charts: Custom SVG, CSS Grid for layouts
- No emojis in UI
+
+Always document non-obvious logic changes with comments
\ No newline at end of file
diff --git a/client/src/App.vue b/client/src/App.vue
index c2da05a5c..dd6849b1c 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -22,6 +22,9 @@
| {{ t('orders.table.orderNumber') }} | +{{ t('orders.table.warehouse') }} | +{{ t('orders.table.items') }} | +{{ t('orders.table.status') }} | +{{ t('orders.table.orderDate') }} | +{{ t('orders.table.expectedDelivery') }} | +{{ t('orders.leadTime') }} | +{{ t('orders.table.totalValue') }} | +
|---|---|---|---|---|---|---|---|
| {{ order.order_number }} | +{{ order.warehouse }} | +
+
+
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} ++
+
+
+ {{ translateProductName(item.name) }}
+
+
+ |
+ + {{ t('status.submitted') }} + | +{{ formatDate(order.order_date) }} | +{{ formatDate(order.expected_delivery) }} | +{{ t('orders.leadTimeDays', { count: order.lead_time_days }) }} | + +{{ currencySymbol }}{{ order.total_value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }} | +
{{ t('restocking.description') }}
+{{ t('restocking.budgetHint') }}
+{{ t('restocking.noShortfall') }}
+{{ t('restocking.noRecommendations') }}
+| {{ t('restocking.table.sku') }} | +{{ t('restocking.table.itemName') }} | +{{ t('restocking.table.warehouse') }} | +{{ t('restocking.table.shortfall') }} | +{{ t('restocking.table.quantity') }} | +{{ t('restocking.table.unitCost') }} | +{{ t('restocking.table.lineTotal') }} | +
|---|---|---|---|---|---|---|
| {{ item.sku }} | +{{ translateProductName(item.itemName) }} | +{{ translateWarehouse(item.warehouse) }} | ++ {{ t('restocking.shortBy', { count: item.shortfall }) }} + | +{{ item.quantity }} | + +{{ formatCurrencyWithDecimals(item.unit_cost, currentCurrency, 2) }} | +{{ formatCurrency(item.lineTotal, currentCurrency) }} | +
Architecture Reference
++ A full-stack demo application: a Vue 3 single-page app backed by a read-only Python + FastAPI service that serves static JSON fixtures held in process memory. + Built as a Claude Code workshop project. +
+ ++ Three tiers, one direction. The browser owns all state and rendering; the API is a stateless + filter over lists loaded once at import; the data tier is seven JSON files read at boot and + never written back. +
+ +router-view, app-level modals. Owns all global CSS (unscoped).useFilters, useI18n, useAuth — module-scope refs as singleton stores. No Pinia or Vuex.http://localhost:8001/api · no Vite proxy
+ apply_filters() for warehouse, category and status; filter_by_month() plus QUARTER_MAP for time.allow_origins=["*"] with allow_credentials=True.json.loads every file at import time into 9 module globals.+ Every arrow points one way. There is no write path, no cache, no background job and no + inter-service communication. A restart returns the system to exactly its initial state, + which is what makes it safe as a demo and unsuitable as a foundation for production work. +
+
+ Versions below are the resolved ones from client/package.json and
+ server/uv.lock, not the looser ranges declared in the manifests.
+
client/ · npm
+
+ Deliberately minimal: no TypeScript, linter, CSS framework, chart library or state
+ library. Charts are hand-rolled SVG and CSS Grid; the design system is literal hex
+ values in App.vue rather than CSS custom properties.
+
server/ · uv
+
+ Dependencies are declared twice — in pyproject.toml and a legacy
+ requirements.txt. All route handlers are sync def, so FastAPI
+ runs them in its threadpool.
+
tests/ · scripts/ · .claude/
+
+ No CI, no Dockerfile and no deployment config exist. scripts/start.sh and
+ stop.sh manage both servers locally and are macOS/Linux only.
+
+ The global filter system is the application's central mechanism: four controls in one bar + drive every view on every page. Following one filter change end to end explains most of the + codebase. +
+ +
+ FilterBar.vue renders four selects — Time Period, Location, Category,
+ Order Status — each v-model-bound straight to a shared ref. All four
+ default to 'all'.
+
+ In composables/useFilters.js the four ref()s are declared
+ outside the exported function. Every importer receives the same instances —
+ that single detail is the entire global store. State is not persisted and not synced to
+ the URL, so a page reload resets all filters.
+
+ Views watch all four refs and call loadData() on any change.
+ There is no debounce and no request cancellation, so rapid changes issue overlapping
+ requests.
+
+ getCurrentFilters() renames selectedLocation to
+ warehouse, and adds month only when the period is not
+ 'all'.
+
+ api.js assembles URLSearchParams, omitting any value equal to
+ 'all', then issues an axios GET against the hardcoded base URL.
+
+ apply_filters() matches warehouse exactly and category and status
+ case-insensitively. filter_by_month() matches against
+ order_date and accepts either a direct YYYY-MM prefix or a
+ quarter key, which QUARTER_MAP expands into its member months.
+
+ Seven routes declare a response_model and are validated and coerced. The
+ other seven return bare dicts and lists, so their shapes are enforced nowhere.
+
+ Responses are stored unmodified in view-local refs such as allOrders and
+ inventoryItems. Every total, percentage and chart series is a
+ computed() over those refs — the convention that keeps derived values
+ from drifting out of sync.
+
+ The filter bar is global, but most endpoints ignore most of it. Only two routes accept the + full set. +
+| Endpoint | +Warehouse | Category | Status | Month | +Note | +
|---|---|---|---|---|---|
| /api/orders | +yes | yes | yes | yes | +Full filter support | +
| /api/dashboard/summary | +yes | yes | yes | yes | +Status and month apply to orders only | +
| /api/inventory | +yes | yes | — | — | +Inventory has no time dimension | +
| /api/demand | +— | — | — | — | +Returns the full list | +
| /api/backlog | +— | — | — | — | +Returns the full list | +
| /api/spending/* | +— | — | — | — | +Pre-aggregated fixtures | +
| /api/reports/* | +— | — | — | — | +Aggregates all 250 orders every call | +
+ Because the filter bar is rendered globally in App.vue but most endpoints
+ ignore it, changing a filter on the Demand, Spending or Reports pages appears to do
+ nothing. That is the current design, not a rendering bug.
+
+ All 14 routes defined in server/main.py. Every one is a GET; the service has no
+ write surface. Routes marked untyped return bare dicts or
+ lists with no schema validation.
+
| Method | Path | Parameters | Response | Purpose | +
|---|---|---|---|---|
| GET | +/ | — | +untyped | +Service name and version | +
| GET | +/api/inventory | +warehouse, category | +List[InventoryItem] | +Stock levels per SKU | +
| GET | +/api/inventory/{item_id} | +path param | +InventoryItem | +Single item; 404 if absent | +
| GET | +/api/orders | +warehouse, category, status, month | +List[Order] | +Customer orders | +
| GET | +/api/orders/{order_id} | +path param | +Order | +Single order; 404 if absent | +
| GET | +/api/demand | — | +List[DemandForecast] | +Forecast vs current demand | +
| GET | +/api/backlog | — | +List[BacklogItem] | +Shortages; injects has_purchase_order |
+
| GET | +/api/dashboard/summary | +warehouse, category, status, month | +untyped | +Five headline metrics | +
| GET | +/api/spending/summary | — | +untyped | +Spending totals | +
| GET | +/api/spending/monthly | — | +untyped | +12 months by cost type | +
| GET | +/api/spending/categories | — | +untyped | +4 categories with share | +
| GET | +/api/spending/transactions | — | +untyped | +56 recent transactions | +
| GET | +/api/reports/quarterly | — | +untyped | +Per-quarter revenue and fulfilment rate | +
| GET | +/api/reports/monthly-trends | — | +untyped | +Per-month orders, revenue, deliveries | +
+ client/src/api.js exports 17 methods. Six of them call endpoints that do not
+ exist in server/main.py and will return 404.
+
| Client method | Call | Server route | Effect |
|---|---|---|---|
| getTasks() | GET /api/tasks | +missing | +Called on mount in App.vue; falls back to mock tasks |
| createTask() | POST /api/tasks | +missing | Task creation never persists |
| deleteTask() | DELETE /api/tasks/{id} | +missing | Deletion never persists |
| toggleTask() | PATCH /api/tasks/{id} | +missing | Toggle never persists |
| createPurchaseOrder() | POST /api/purchase-orders | +missing | No caller — dead code |
| getPurchaseOrderByBacklogItem() | GET /api/purchase-orders/{id} | +missing | No caller — dead code |
+ Two further methods, getInventoryItem() and getOrder(), map to real
+ routes but have no callers. Conversely, Reports.vue bypasses
+ api.js entirely and calls both /api/reports/* routes with axios
+ directly.
+
+ Seven fixture files in server/data/, loaded once into nine module globals.
+ Counts below are the actual record counts in the checked-in files.
+
| File | Entity | Records | Key fields |
|---|---|---|---|
| inventory.json | Stock item / SKU | 32 | +sku, category, warehouse, quantity_on_hand, reorder_point, unit_cost | +
| orders.json | Customer order | 250 | +order_number, customer, items[], status, warehouse, category, order_date, total_value | +
| demand_forecasts.json | Demand forecast | 9 | +item_sku, current_demand, forecasted_demand, trend, period | +
| backlog_items.json | Shortage | 4 | +order_id, item_sku, quantity_needed, quantity_available, days_delayed, priority | +
| transactions.json | Transaction | 56 | +date, description, category, warehouse, amount, vendor, type | +
| spending.json | Spending aggregates | 3 sections | +spending_summary, monthly_spending (12), category_spending (4) | +
| purchase_orders.json | Purchase order | 0 | +Empty array — see Known Gaps | +
| Dimension | Values |
|---|---|
| Warehouses | San Francisco · London · Tokyo |
| Categories | Circuit Boards · Sensors · Actuators · Controllers · Power Supplies |
| Order statuses | Delivered · Shipped · Processing · Backordered |
| Date span | 2025-01-02 to 2025-12-31 (all 250 orders fall in calendar 2025) |
| Spending categories | Raw Materials · Components · Equipment · Consumables |
| Locales | en, ja — hand-rolled i18n; JPY conversion hardcoded at 150 |
+ Inventory and orders share the same five categories, so cross-filtering works. Spending + uses an entirely separate four-value vocabulary that does not intersect — which is + why spending cannot be filtered by the global category control. +
+Where each concept lives.
+client/ Vue 3 SPA + index.html Vite entry, mounts #app + vite.config.js dev server on :3000 (no proxy) + src/ + main.js createApp + router, 6 routes + App.vue shell, global CSS, task state + api.js axios client, 17 methods + views/ 7 files: 6 routed + Backlog.vue (dead) + components/ FilterBar, ProfileMenu, 6 modals + composables/ useFilters, useI18n, useAuth + locales/ en.js, ja.js translation maps + utils/ currency.js formatting + conversion + +server/ FastAPI service + main.py routes, models, filters — the whole app + mock_data.py loads fixtures at import + generate_data.py standalone generator (stale vs fixtures) + pyproject.toml / uv.lock uv-managed dependencies + data/ 7 JSON fixtures + +tests/ pytest + FastAPI TestClient + pytest.ini testpaths = backend + backend/ conftest.py + 3 test files, 40 tests + +scripts/ start.sh / stop.sh (macOS + Linux only) +docs/ screenshot + this page +.claude/ agents, commands, skills, hooks, MCP config+ +
| Task | Command |
|---|---|
| Start both servers | ./scripts/start.sh |
| Backend only | cd server && uv run python main.py |
| Frontend only | cd client && npm install && npm run dev |
| Backend tests | cd tests && uv run pytest backend/ -v |
| Production build | cd client && npm run build |
| Interactive API docs | http://localhost:8001/docs |
+ Verified against the source, not inferred. These materially affect how the architecture above + should be read — several parts of the system are modelled but not wired up. Each entry + cites its location so it stays checkable as the code changes. +
+ +
+ api.js defines four task methods against /api/tasks and two
+ purchase-order methods against /api/purchase-orders. The server defines
+ neither. App.vue calls getTasks() in onMounted, so
+ the request 404s and the task UI silently runs on the mock array in useAuth.js
+ — task edits look like they work but vanish on reload.
+
+ Dashboard.vue renders <PurchaseOrderModal> with props and
+ handlers, but the component is never imported, never registered, and no such file exists
+ anywhere in the repo. Vue logs an unresolved-component warning and renders nothing. The
+ supporting state and handlers are all present and unreachable.
+
+ PurchaseOrder and CreatePurchaseOrderRequest are defined in
+ main.py and referenced by no route. purchase_orders.json is an
+ empty array, so has_purchase_order on /api/backlog is always
+ false. Combined with the two gaps above, the feature exists as scaffolding at
+ every layer and functions at none.
+
+ Alone among the views it uses the Options API rather than Composition, calls axios directly
+ instead of going through api.js, has no i18n coverage, and ignores the global
+ filter bar entirely. It also carries leftover console.log statements.
+
+ The view is not routed and not imported anywhere. Its functionality is duplicated inside the + Dashboard's Inventory Shortages card, which is the copy users actually see. +
+ client/src/views/Backlog.vue +
+ QUARTER_MAP and the quarterly report enumerate 2025 months literally rather
+ than deriving them from the data. All fixture orders fall in 2025 today, so this is
+ invisible — and would silently return empty results against any other year.
+
+ tests/README.md and tests/TEST_SUMMARY.md both describe
+ test_orders.py with 15 tests, claiming totals of 51 and 55. The file is absent
+ from disk and from the entire git history. The real total is 40 tests across 3 files, with
+ no coverage of /api/orders or either reports route.
+
+ .claude/hooks/post-tool-use.sh exists and is documented, but
+ settings.local.json contains no hooks block, so it never runs. The
+ hooks README also documents a user-prompt-submit.sh that is not on disk, and
+ states a log path that differs from the one the script writes to. Two competing MCP configs
+ exist: .mcp.json pins Playwright 0.0.37 while
+ .claude/mcp-config.json uses @latest.
+
+ CORS is allow_origins=["*"] with allow_credentials=True; the
+ backend URL is hardcoded client-side with no proxy or environment variable; there is no
+ auth, since useAuth.isAuthenticated is a literal true and
+ logout() only raises an alert. Appropriate for a local workshop demo, and all
+ three are blockers for anything else. The README says as much.
+