From eeeebc15720d4b34ad7ffcc9fce167169f1460da Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 6 Feb 2026 13:12:18 +0000 Subject: [PATCH] docs: consolidate CLAUDE.md from 1212 to 607 lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorganise from single mega-section into layer-based structure (Backend, Datastar/Frontend, Design System, Tables/Lists) - Promote 8 critical gotchas into dedicated top-level section - Deduplicate signal naming (3x), route patterns (3x), form patterns - Replace code examples with macro reference table pointing to source - Trim design token table to name→class mapping (reference input.css) - Consolidate error/logging rules into single section - All patterns, rules, and conventions preserved --- CLAUDE.md | 1222 ++++++++++++++--------------------------------------- 1 file changed, 309 insertions(+), 913 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d16d252..fe1f4ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,32 +82,44 @@ src/ **Dependency flow**: `presentation → application → domain ← infrastructure` -## Code Patterns +## Gotchas + +These are non-obvious footguns that will cause bugs if missed. + +**1. `datastar-fetch` event bubbles through the DOM.** When a page has multiple forms with `data-on:datastar-fetch` handlers, each handler fires for events from *any* `@post`/`@get` in the same DOM tree. **Every handler must guard with its own in-progress signal**: + +```html +
+``` + +Only reset state on `finished` or `error`, never unconditionally. + +**2. No `data-model` in Datastar v1** — it is silently ignored. Use `data-bind:_signal-name` for two-way binding. + +**3. Signal patching requires JSON, not HTML.** Datastar only processes signal updates from `application/json` responses (via `render_signals_json()`), not from `data-signals` attributes in DOM-patched HTML fragments. + +**4. List partial must be OUTSIDE the form section.** In page templates, the `{% include %}` for the list partial must be a **sibling** of the form `
`, not nested inside it. Placing it inside removes the flex gap between form and list. + +**5. Table wrapper must be `
`, not `
`.** List partials wrap the table in `
`. + +**6. Infinite scroll sentinel needs `md:hidden`.** The sentinel `
` must include `md:hidden` to avoid unwanted height on desktop. Same applies when creating sentinels dynamically in JS. + +**7. Use token-based text classes, never hardcoded `text-stone-*`.** Always use `text-text`, `text-text-secondary`, `text-text-muted` which adapt between light and dark themes. + +**8. Static assets need explicit routes.** All assets are embedded at compile time via `include_str!()`/`include_bytes!()` with explicit routes in `application/routes/mod.rs`. There is no `tower-http` static file serving. + +## Backend Patterns ### Repository Pattern -All data access goes through trait-based repositories defined in `domain/repositories.rs`: - -```rust -#[async_trait] -pub trait RoasterRepository { - async fn insert(&self, roaster: NewRoaster) -> Result; - async fn get(&self, id: RoasterId) -> Result; - // ... -} -``` - -SQL implementations live in `infrastructure/repositories/`. Each uses a private `Record` struct (e.g., `BagRecord`) with a `to_domain()` method to convert from database row to domain entity: - -```rust -impl BagRecord { - fn to_domain(self) -> Bag { ... } -} -``` +All data access goes through trait-based repositories defined in `domain/repositories.rs`. SQL implementations live in `infrastructure/repositories/`, each using a private `Record` struct with a `to_domain()` method to convert database rows to domain entities. Use typed ID wrappers from `domain/ids.rs` (e.g., `RoastId`, `BagId`) — never raw `i64`. ### Service Layer -Entity services in `application/services/` encapsulate "create entity + record timeline event" as a single operation. Repositories are pure data access with no side effects; services add the timeline side effect on top. +Services in `application/services/` encapsulate "create entity + record timeline event" as a single operation. **When to use services vs repos:** - **Services** — for `create()` (and `finish()` for bags). These record a timeline event after the insert. @@ -115,19 +127,9 @@ Entity services in `application/services/` encapsulate "create entity + record t `AppState` holds both repos and services. Route handlers call `state.xxx_service.create()` for creation and `state.xxx_repo.get()` / `.list()` / etc. for reads and updates. -#### `define_simple_service!` macro +The `define_simple_service!` macro in `services/mod.rs` generates services for entities whose `to_timeline_event()` needs only `&self`. This covers `RoasterService`, `CafeService`, `GearService`. -For entities whose `to_timeline_event()` needs only `&self` (no cross-entity lookups), the macro in `services/mod.rs` generates the struct, constructor, and `create` method: - -```rust -define_simple_service!(RoasterService, RoasterRepository, Roaster, NewRoaster, "roaster"); -``` - -This covers: `RoasterService`, `CafeService`, `GearService`. - -#### Custom services - -Entities needing enrichment or related-entity lookups are written by hand: +Entities needing enrichment are hand-written: | Service | Extra repos | Why | |---------|-------------|-----| @@ -136,9 +138,7 @@ Entities needing enrichment or related-entity lookups are written by hand: | `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response | | `CupService` | — | `create()` enriches via `get_with_details()` for timeline | -#### Timeline events - -Each entity type has a `to_timeline_event()` method (or standalone function) in its domain file that builds a `NewTimelineEvent`. Services call these methods and insert the result via `timeline_repo` with fire-and-forget error handling: +Timeline events are display-only (not data integrity), so they use fire-and-forget error handling: ```rust if let Err(err) = self.timeline_repo.insert(entity.to_timeline_event()).await { @@ -146,446 +146,275 @@ if let Err(err) = self.timeline_repo.insert(entity.to_timeline_event()).await { } ``` -Timeline events are display-only (cosmetic, not data integrity), so they are not in the same transaction as the entity insert. +### Route Module Structure -### Typed IDs +Each list-bearing route module (roasters, roasts, bags, gear, brews) follows the same structure: -Use the typed ID wrappers from `domain/ids.rs` to prevent mixing up IDs: +1. **Path constants** — `ENTITY_PAGE_PATH` (full page URL) and `ENTITY_FRAGMENT_PATH` (with `#entity-list` anchor) +2. **`load_entity_page()`** — calls `repo.list()` and builds view models via `build_page_view()` from `support.rs` +3. **`entity_page()`** — checks `is_datastar_request()`: returns fragment for Datastar, full page otherwise +4. **`render_entity_list_fragment()`** — returns just the list partial for Datastar replacement + +Create handlers follow a three-way response pattern: ```rust -// Good -fn get_roast(&self, id: RoastId) -> Result - -// Bad - raw i64 could be any ID type -fn get_roast(&self, id: i64) -> Result -``` - -### SQL Query Construction - -Use `QueryBuilder` for dynamic queries. For UPDATE queries, use the `push_update_field!` macro: - -```rust -use super::macros::push_update_field; - -let mut builder = QueryBuilder::new("UPDATE roasters SET "); -let mut sep = false; - -push_update_field!(builder, sep, "name", changes.name); -push_update_field!(builder, sep, "country", changes.country); -// ... more fields - -if !sep { - return Err(RepositoryError::unexpected("No fields provided for update")); -} - -builder.push(" WHERE id = "); -builder.push_bind(i64::from(id)); -``` - -### Sorting/Ordering - -Each repository has an `order_clause()` method for consistent sort query generation: - -```rust -fn order_clause(request: &ListRequest) -> String { - let dir_sql = match request.sort_direction() { - SortDirection::Asc => "ASC", - SortDirection::Desc => "DESC", - }; - match request.sort_key() { - RoasterSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"), - // ... - } +if is_datastar_request(&headers) { + render_fragment(...) // Datastar → updated list fragment +} else if matches!(source, PayloadSource::Form) { + Redirect::to(...) // Browser form → redirect +} else { + Json(entity) // API → JSON } ``` -### CLI Commands +### Macros Reference -For simple get/delete commands, use the macros in `presentation/cli/macros.rs`: +All macros have doc comments with usage examples. Check the source files for full documentation. -```rust -use super::macros::{define_get_command, define_delete_command}; +| Macro | Location | Purpose | +|-------|----------|---------| +| `define_simple_service!` | `application/services/mod.rs` | Generate service struct with `create()` + timeline | +| `define_get_handler!` | `application/routes/api/macros.rs` | GET `/api/v1/:entity/:id` → JSON | +| `define_enriched_get_handler!` | `application/routes/api/macros.rs` | GET with joined related entities → JSON | +| `define_delete_handler!` | `application/routes/api/macros.rs` | DELETE → fragment for Datastar or 204 for API | +| `define_list_fragment_renderer!` | `application/routes/api/macros.rs` | Generate fragment renderer for a list page | +| `define_get_command!` | `presentation/cli/macros.rs` | CLI get-entity command | +| `define_delete_command!` | `presentation/cli/macros.rs` | CLI delete-entity command | +| `push_update_field!` | `infrastructure/repositories/macros.rs` | Build dynamic UPDATE queries with `QueryBuilder` | -define_get_command!(GetRoasterCommand, get_roaster, RoasterId, roasters); -define_delete_command!(DeleteRoasterCommand, delete_roaster, RoasterId, roasters, "roaster"); -``` +### SQL & Queries -### Datastar Integration +Use `QueryBuilder` for dynamic queries. For UPDATE, use `push_update_field!` (see macro docs). Each repository has an `order_clause()` method for sort query generation — use `order_clause` as the method name, not `sort_clause`. -The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads. This provides HTMX-style interactions with a declarative API. +### Error Handling & Logging -#### Request Detection +**Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI). -Datastar requests are identified by the `datastar-request: true` header: +**Logging**: `tracing` + `tracing-subscriber` with `tower-http` `TraceLayer`. Configure via `RUST_LOG` (default `info`) and `RUST_LOG_FORMAT=json` for structured output. -```rust -// application/routes/support.rs -pub fn is_datastar_request(headers: &HeaderMap) -> bool { - headers - .get("datastar-request") - .and_then(|value| value.to_str().ok()) - .map(|value| value.eq_ignore_ascii_case("true")) - .unwrap_or(false) -} -``` +**Error logging rules** — never silently discard errors: -#### Fragment Rendering +1. **`map_err(|_| StatusCode::*)` patterns** — log the original error before mapping. Use `warn!` for client-caused failures, `error!` for server-side. +2. **`.ok()` / `.ok()?` patterns** — replace with explicit match that logs before returning `None`. +3. **Fire-and-forget (`let _ = ...`)** — use `if let Err(err) = ...` and log. +4. **Background tasks (`tokio::spawn`)** — log inside the spawned future. -When a Datastar request is detected, return a fragment instead of a full page: +**CRUD logging**: Every successful create/update/delete logs at `info!` with entity ID and key fields. -```rust -pub(crate) async fn roasters_page(...) -> Result { - let (request, search) = query.into_request_and_search::(); +**Security logging**: Auth events (login, logout, token create/revoke, passkey delete) log at `info!` with user ID. - if is_datastar_request(&headers) { - // Datastar request → return fragment only - return render_roaster_list_fragment(state, request, search, is_authenticated).await; - } +### Foursquare Integration - // Traditional request → return full page with layout - let template = RoastersTemplate { ... }; - render_html(template).map(IntoResponse::into_response) -} -``` +Nearby cafe search uses the Foursquare Places API. Set `BREWLOG_FOURSQUARE_API_KEY`. See `infrastructure/foursquare.rs` for the implementation and `tests/server/nearby_api.rs` for the `wiremock`-based test pattern. -Fragments are rendered with special headers that tell Datastar where to patch the DOM: +## Datastar & Frontend -```rust -// application/routes/support.rs -pub fn render_fragment(template: T, selector: &'static str) -> Result { - let html = render_template(template)?; - let mut response = Html(html).into_response(); - response.headers_mut().insert("datastar-selector", HeaderValue::from_static(selector)); - response.headers_mut().insert("datastar-mode", HeaderValue::from_static("replace")); - Ok(response) -} -``` +### Core Concepts -#### Frontend Attributes +The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads. -Templates use Datastar attributes for interactivity: +Key attributes: + +| Attribute | Purpose | Example | +|-----------|---------|---------| +| `data-signals:_name="value"` | Declare local signal (underscore = not sent to server) | `data-signals:_show-form="false"` | +| `data-show="$_signal"` | Conditional visibility | `data-show="$_showForm"` | +| `data-bind:_signal-name` | Two-way binding to input value | `data-bind:_roaster-name` | +| `data-on:event="expr"` | Event handler | `data-on:submit="$_submitting = true; @post(...)"` | +| `data-ref="_name"` | DOM element reference | `data-ref="_form"` | +| `data-text="$_signal"` | Set text content from signal | `data-text="$_cafeName"` | +| `data-attr:attr="$_signal"` | Set attribute from signal | `data-attr:value="$_roastId"` | +| `@get/@post/@put/@delete` | HTTP actions with Datastar headers | `@post('/api/v1/roasters', {contentType: 'form'})` | + +### Signal Naming + +Signal names use **kebab-case** in HTML attributes and auto-convert to **camelCase** in JS expressions and JSON: + +- HTML: `data-signals:_roaster-name="''"` or `data-bind:_roaster-name` +- JS expression: `$_roasterName` +- JSON key: `_roasterName` + +**Naming conventions for common signals:** + +| Signal | Purpose | +|--------|---------| +| `_extracting` | AI extraction in progress | +| `_submitting` | Form save/create in progress | +| `_extract-error` / `_error` | Error message | +| `_show-{thing}` | Boolean visibility toggle | + +### Response Types + +Two response formats exist for Datastar: + +**HTML fragments** — for replacing DOM sections. Use `render_fragment(template, selector)` from `support.rs`, which sets `datastar-selector` and `datastar-mode: replace` headers. + +**JSON signal patches** — for updating signal values (e.g., AI extraction filling form fields). Use `render_signals_json(&[("_signal-name", value)])` from `support.rs`. Signal keys are passed in kebab-case; the function converts to camelCase for the JSON response. + +### Datastar vs JavaScript + +**Use Datastar for:** +- Visibility toggling (`data-show` + signals) +- List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render +- Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides` +- AI extraction signal patching +- Multi-step wizards — step signals (`$_step`) with `data-show="$_step === N"` +- Searchable selection lists — `` with `data-on:change` + +**Use JavaScript for:** +- Browser APIs: WebAuthn, clipboard, geolocation, FileReader +- Infinite scroll (`IntersectionObserver` in `base.html`) +- Theme toggle — must run in `` before DOM renders +- Any flow requiring `window.location.reload()` after completion + +### AI Extraction Pattern + +Pages with AI-powered form filling use a Datastar-native pattern. Extraction endpoints return JSON signal patches that Datastar merges into the signal store, and `data-bind` pushes values into form fields automatically. + +Template structure: ```html - -
+
+ + + +
+ + Take Photo + + + +
+
+ + - -
- + +
-
- - - -
``` -Key attributes: - -- `data-signals:_name="value"` — Local signals (underscore prefix excludes from backend requests) -- `data-show="$_signal"` — Conditional visibility -- `data-bind:_signal-name` — Two-way binding between signal and input value -- `data-on:event="expression"` — Event handlers -- `data-ref="_name"` — DOM element references (underscore prefix for local refs) -- `data-text="$_signal"` — Set element text content from signal -- `data-attr:value="$_signal"` — Set element attribute from signal (used for hidden inputs) -- `@get/@post/@put/@delete(url, options)` — HTTP actions with automatic Datastar headers - -#### Signal Naming - -Signal names use kebab-case in HTML attributes and auto-convert to camelCase in JS expressions: - -- HTML attribute: `data-signals:_roaster-name="''"` or `data-bind:_roaster-name` -- JS expression: `$_roasterName` -- JSON response key: `_roasterName` (camelCase) - -#### Two-Way Binding - -Use `data-bind:_signal-name` for two-way binding between signals and form inputs. **Do not use `data-model`** — it does not exist in Datastar v1 and is silently ignored. - -#### URL Generation - -`ListNavigator` generates URLs for pagination and sorting: +Server-side handler: ```rust -// presentation/web/views.rs -navigator.page_href(2) // "/roasters?page=2&..." (full page) -navigator.fragment_page_href(2) // "/roasters?page=2&...#roaster-list" (fragment) -navigator.sort_href(key) // "/roasters?sort=name&dir=..." +let signals = vec![ + ("_roaster-name", Value::String(result.name)), + ("_roaster-country", Value::String(result.country)), +]; +render_signals_json(&signals) ``` -#### Flexible Payload Handling +### Web Components -Handlers accept both JSON and form data via `FlexiblePayload`. When form fields don't map directly to the domain `New*` struct (e.g., the form sends a roaster name that needs to be resolved to an ID), use a `*Submission` newtype that handles the conversion: +**``** (`static/js/components/photo-capture.js`): -```rust -pub(crate) async fn create_roaster( - payload: FlexiblePayload, // simple — form maps 1:1 to domain - // or: FlexiblePayload // submission type — needs conversion -) -> Result { - let (new_roaster, source) = payload.into_parts(); +| Attribute | Purpose | +|-----------|---------| +| `target-input` | ID of hidden `` that receives the data URL | +| `target-form` | ID of `
` to submit after reading the photo | - if is_datastar_request(&headers) { - render_fragment(state, request, true).await // Return updated fragment - } else if matches!(source, PayloadSource::Form) { - Ok(Redirect::to(&target).into_response()) // Traditional form redirect - } else { - Ok((StatusCode::CREATED, Json(roaster)).into_response()) // JSON API - } -} -``` +Clicking the element opens the camera/file picker, reads the file as a data URL, sets the target input, and submits the form. -### Route Handler Macros +**``** (`static/js/components/searchable-select.js`): -For simple get/delete API handlers, use the macros in `application/routes/macros.rs`: +| Attribute | Purpose | +|-----------|---------| +| `name` | Name for hidden `` in form submission | +| `placeholder` | Search input placeholder (default: "Type to search...") | -```rust -use super::macros::{define_get_handler, define_enriched_get_handler, define_delete_handler}; +| Event | Detail | +|-------|--------| +| `change` | `{ value, display, data }` — fires on selection | +| `clear` | Fires when selection is cleared | -// GET /api/v1/roasters/:id → returns JSON -define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo); +Place `