diff --git a/CLAUDE.md b/CLAUDE.md index a13c019..b752e80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,111 +2,58 @@ ## Project Overview -Brewlog is a self-hosted coffee logging platform built in Rust. It provides: - -- HTTP server with web UI (Axum + Askama templates + Datastar) -- REST API for programmatic access -- CLI client for command-line operations -- SQLite database +Brewlog is a self-hosted coffee logging platform built in Rust: HTTP server (Axum + Askama + Datastar), REST API, CLI client, SQLite database. ## Build & Test Commands -Use `prek` to run the lints, tests and formatters all-in-one: - ```bash -prek run -av +prek run -av # All lints, tests, formatters +cargo build # Build +cargo test # Tests +cargo clippy --allow-dirty --fix # Lint + auto-fix +nix fmt # Format +sqlx migrate add # New migration → migrations/NNNN_.sql ``` -Individual checks can be run with `prek` if needed: - -```bash -prek run clippy -av -prek run cargo-test -av -``` - -Or the individual commands themselves: - -```bash -cargo build # Build the project -cargo test # Run all tests -cargo clippy --allow-dirty --fix # Lint and auto-fix -nix fmt # Format code -``` - -### Database Migrations - -Create new migrations using sqlx: - -```bash -sqlx migrate add # Creates migrations/NNNN_.sql -``` - -Migration files are plain SQL in the `migrations/` directory, numbered sequentially (e.g., `0008_remove_gear_notes.sql`). - ## Workflow Requirements **Before finishing any task**, always: -1. Run `cargo clippy --allow-dirty --fix && cargo fmt` to lint and format -2. Run `cargo build` to verify compilation -3. Run `cargo test` if changes affect testable code -4. Update `README.md` if the change adds/removes/renames CLI commands, environment variables, or user-facing features -5. Update `scripts/bootstrap-db.sh` if the change adds/removes/renames CLI commands, flags, or entity fields used by the bootstrap script -6. Provide a **draft commit message** using Conventional Commits format - -Example commit message: - -``` -feat(gear): add category filtering to gear list - -- Add GearFilter with optional category field -- Update repository to apply filter in SQL WHERE clause -- Add --category flag to CLI list-gear command - -``` +1. Run `prek run -av` +1. Consider if the test coverage needs updating +1. Update `README.md` if the change adds/removes/renames CLI commands, env vars, or user-facing features +1. Update `scripts/bootstrap-db.sh` if the change affects CLI commands, flags, or entity fields used by it +1. Provide a **draft commit message** using Conventional Commits format ## Architecture -The codebase follows **Clean Architecture / Domain-Driven Design** with four layers: +Clean Architecture / DDD with four layers: ``` src/ -├── domain/ # Pure business logic, no external dependencies +├── domain/ # Pure business logic, no external deps │ ├── errors.rs # RepositoryError enum -│ ├── ids.rs # Typed ID wrappers (RoasterId, RoastId, BagId, BrewId, GearId, CafeId, CupId, etc.) +│ ├── ids.rs # Typed ID wrappers (RoasterId, BagId, BrewId, etc.) │ ├── listing.rs # Pagination & sorting (SortKey, ListRequest, Page, PageSize) │ ├── repositories.rs # Repository traits -│ ├── countries.rs # Country name → ISO code mapping, flag emoji conversion -│ ├── formatting.rs # Shared display formatting helpers +│ ├── countries.rs # Country name → ISO code, flag emoji +│ ├── formatting.rs # format_relative_time(), format_weight() │ ├── coffee/ # roasters, roasts, bags, brews, cups, gear, cafes -│ ├── auth/ # users, sessions, tokens, passkey_credentials, registration_tokens +│ ├── auth/ # users, sessions, tokens, passkeys, registration_tokens │ └── analytics/ # timeline, stats, country_stats, ai_usage -│ -├── infrastructure/ # External integrations (database, HTTP clients, third-party APIs) -│ ├── repositories/ # SQL implementations of repository traits -│ │ ├── coffee/ # Coffee entity repos -│ │ ├── auth/ # Auth repos -│ │ └── analytics/ # Analytics repos (timeline_events, stats, ai_usage) +├── infrastructure/ # DB, HTTP clients, third-party APIs +│ ├── repositories/ # SQL impls of repository traits (coffee/, auth/, analytics/) │ ├── client/ # HTTP client for CLI -│ ├── ai.rs # OpenRouter LLM integration for AI extraction -│ ├── foursquare.rs # Foursquare Places API for nearby cafe search +│ ├── ai.rs # OpenRouter LLM integration +│ ├── foursquare.rs # Foursquare Places API │ ├── backup.rs # Database backup/restore -│ ├── webauthn.rs # WebAuthn/passkey credential storage -│ └── database.rs # Database pool abstraction -│ +│ └── database.rs # Database pool + SQLite pragmas ├── application/ # HTTP server, routes, middleware, services -│ ├── routes/ # Axum route handlers -│ │ ├── api/ # REST API -│ │ │ ├── coffee/ # Entity CRUD + extract + scan + checkin -│ │ │ ├── auth/ # Tokens, WebAuthn -│ │ │ ├── analytics/ # Stats -│ │ │ └── system/ # Admin, backup -│ │ └── app/ # Web UI page routes -│ ├── services/ # Entity services (create + timeline orchestration) +│ ├── routes/ # Axum handlers (api/ for REST, app/ for web UI) +│ ├── services/ # Entity services (create + timeline event) │ └── errors.rs # HTTP error mapping -│ └── presentation/ # User interfaces - ├── cli/ # CLI commands and argument parsing + ├── cli/ # CLI commands └── web/ # View models for templates ``` @@ -116,7 +63,7 @@ src/ 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**: +**1. `datastar-fetch` event bubbles through the DOM.** Every `data-on:datastar-fetch` handler must guard with its own in-progress signal, or it fires for events from _any_ `@post`/`@get` in the DOM tree: ```html
``` -Only reset state on `finished` or `error`, never unconditionally. +**2. No `data-model` in Datastar v1** — silently ignored. Use `data-bind:_signal-name`. -**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.** Use `render_signals_json()`, not `data-signals` in DOM fragments. -**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** — sibling of the form `
`, not nested inside it. -**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 `
`** — `
`. -**5. Table wrapper must be `
`, not `
`.** List partials wrap the table in `
`. +**6. Infinite scroll sentinel needs `md:hidden`** — `
`. -**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 `text-stone-*`.** Use `text-text`, `text-text-secondary`, `text-text-muted`. -**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 and cache headers.** Embedded via `include_str!()`/`include_bytes!()` with explicit routes in `application/routes/app/mod.rs`. All under `/static/`. Every handler must return `cache-control: public, max-age=604800`. -**8. Static assets need explicit routes and cache headers.** All assets are embedded at compile time via `include_str!()`/`include_bytes!()` with explicit routes in `application/routes/app/mod.rs`. All asset routes use the `/static/` prefix (e.g., `/static/css/styles.css`, `/static/js/components/world-map.js`). There is no `tower-http` static file serving. Every static asset handler must return a `cache-control: public, max-age=604800` header alongside `content-type`. +**9. CSP must be updated when adding external resources.** Set in `application/routes/mod.rs`. Datastar requires `'unsafe-inline'` and `'unsafe-eval'` in `script-src`. -**9. CSP must be updated when adding external resources.** The `Content-Security-Policy` header is set in `application/routes/mod.rs`. If you add a new external script, stylesheet, font, or image source, update the corresponding CSP directive (`script-src`, `style-src`, `font-src`, `img-src`) or the browser will block it silently. Datastar requires `'unsafe-inline'` and `'unsafe-eval'` in `script-src`. +**10. Cookie `Secure` flag is on by default.** Set `BREWLOG_INSECURE_COOKIES=true` for local HTTP dev. -**10. Cookie `Secure` flag is on by default.** Session cookies are marked `Secure` unless `BREWLOG_INSECURE_COOKIES=true` is set. Local HTTP development needs this env var in `.env`. Do not use the old `BREWLOG_SECURE_COOKIES` variable — it no longer exists. +**11. URL fields must validate scheme server-side.** Reject non-`http(s)` schemes to prevent XSS. See `is_valid_url_scheme()` in `domain/coffee/roasters.rs`. -**11. URL fields must validate scheme server-side.** Any user-supplied URL field (e.g., roaster `homepage`) must reject non-`http(s)` schemes to prevent `javascript:` or `data:` XSS. Use the `is_valid_url_scheme()` helper in `domain/coffee/roasters.rs` as a reference pattern. - -**12. Datastar create handlers must check referer for fragment targets.** When a `@post` creates an entity and returns a list fragment (e.g., `#brew-list`), that fragment only exists on the entity's data page. If the same `@post` can fire from other pages (homepage, timeline), check the `Referer` header and return a reload-script response for pages that lack the target element. See `create_brew` in `application/routes/api/coffee/brews.rs`. +**12. Datastar create handlers must check referer for fragment targets.** If a `@post` can fire from pages lacking the target element, check `Referer` and return a reload-script. See `create_brew` in `application/routes/api/coffee/brews.rs`. ## Backend Patterns -### SQLite Configuration - -`infrastructure/database.rs` configures SQLite pragmas at connection time: - -| Pragma | Value | Purpose | -| -------------- | -------- | -------------------------------- | -| `foreign_keys` | `ON` | Enforce FK constraints | -| `journal_mode` | `WAL` | Concurrent reads during writes | -| `synchronous` | `NORMAL` | Faster writes (safe with WAL) | -| `cache_size` | `-8000` | 8 MB page cache | -| `temp_store` | `MEMORY` | Temp tables in RAM | -| `busy_timeout` | `5000` | Wait up to 5s on lock contention | - -When adding new pragmas, add them after the existing ones in `Database::connect()`. Connection pool is capped at 5 — appropriate for SQLite's single-writer model. - -### HTTP Middleware Stack - -The middleware stack in `application/routes/mod.rs` applies layers in this order (outermost first): request tracing, cookie parsing, body size limit, security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `CSP`, `HSTS`), and gzip compression. When adding new middleware, place it in the `ServiceBuilder` chain at the appropriate position. - -### Open Graph Meta Tags - -Social media preview cards are powered by Open Graph and Twitter Card meta tags in `templates/base.html`. The base template defines default `og:title`, `og:description`, `og:type`, `og:site_name`, and `twitter:card` tags using Askama blocks (`{% block og_title %}`, `{% block og_description %}`). - -**Base URL** — `BREWLOG_RP_ORIGIN` is stored at startup via `set_base_url()` in `lib.rs` (a `OnceLock`). Templates access it through the `base_url` field on their template struct, set with `crate::base_url()` in the handler. - -**`og:image`** — a static 1200×630 PNG (`static/og-image.png`) served at `/static/og-image.png` via `include_bytes!()`. All detail pages (bag, brew, cafe, cup, gear, roast, roaster), plus home and stats, include it via `{% block head %}`. - -**Adding OG tags to a new page:** - -1. Add `pub base_url: &'static str` to the template struct -2. Set `base_url: crate::base_url()` in the handler -3. Override `{% block og_title %}`, `{% block og_description %}`, and add `` in `{% block head %}` - ### Repository Pattern -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`. +Repositories defined as traits in `domain/repositories.rs`, SQL impls in `infrastructure/repositories/`. Each uses a private `Record` struct with `to_domain()`. Use typed ID wrappers from `domain/ids.rs` — never raw `i64`. ### Service Layer -Services in `application/services/` encapsulate "create entity + record timeline event" as a single operation. +Services (`application/services/`) encapsulate "create + timeline event". Use **services** for `create()` (and `finish()` for bags), **repos** for `get()`/`list()`/`update()`/`delete()`. -**When to use services vs repos:** +`define_simple_service!` macro generates services for `RoasterService`, `CafeService`, `GearService`. Others (`RoastService`, `BagService`, `BrewService`, `CupService`) are hand-written because they need enrichment from related repos. -- **Services** — for `create()` (and `finish()` for bags). These record a timeline event after the insert. -- **Repos** — for `get()`, `list()`, `update()`, `delete()`. No side effects needed. - -`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. - -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`. - -Entities needing enrichment are hand-written: - -| Service | Extra repos | Why | -| -------------- | ---------------------------- | -------------------------------------------------------------------- | -| `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline | -| `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline | -| `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response | -| `CupService` | — | `create()` enriches via `get_with_details()` for timeline | - -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 { - warn!(error = %err, id = %entity.id, "failed to record timeline event"); -} -``` +Timeline events use fire-and-forget: `if let Err(err) = ... { warn!(...) }`. ### Route Module Structure -Each list-bearing route module (roasters, roasts, bags, brews, cups, cafes, gear) follows the same structure: +Each list-bearing route follows: path constants → `load_entity_page()` → `entity_page()` (fragment vs full page via `is_datastar_request()`) → `render_entity_list_fragment()`. -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: +Create handlers use a three-way response pattern: ```rust if is_datastar_request(&headers) { @@ -243,729 +128,127 @@ if is_datastar_request(&headers) { ### Detail Pages -Seven detail pages share layout via extracted template macros and Rust helpers: +Seven detail pages (bag, brew, cafe, cup, gear, roast, roaster) share macros from `templates/partials/detail_cards.html` and helpers from `presentation/web/views/mod.rs` (`build_coffee_info`, `build_roaster_info`, `build_map_data`). -| Page | Route | Shared macros used | -| ------- | ---------------------------------------------- | -------------------------------------------- | -| Bag | `/bags/{id}` | coffee_card, roaster_card, map_with_legend_2 | -| Brew | `/brews/{id}` | coffee_card, roaster_card, map_with_legend_2 | -| Cafe | `/cafes/{slug}` | map_with_legend_1 | -| Cup | `/cups/{id}` | coffee_card, roaster_card, map_with_legend_3 | -| Gear | `/gear/{id}` | (standalone layout) | -| Roast | `/roasters/{roaster_slug}/roasts/{roast_slug}` | coffee_card, roaster_card, map_with_legend_2 | -| Roaster | `/roasters/{slug}` | map_with_legend_1 | +### Macros -**Template macros** — `templates/partials/detail_cards.html` provides: - -| Macro | Parameters | Used by | -| ------------------------ | --------------------------------------------------------------------------------------- | --------------------------- | -| `share_button()` | — | (defined, currently unused) | -| `share_script()` | — | (defined, currently unused) | -| `coffee_card(...)` | roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes | Bag, Brew, Cup, Roast | -| `roaster_card(...)` | name, country, country_flag, city, homepage | Bag, Brew, Cup, Roast | -| `map_with_legend_1(...)` | map_countries, map_max, label1, opacity1 | Cafe, Roaster | -| `map_with_legend_2(...)` | map_countries, map_max, label1, opacity1, label2, opacity2 | Bag, Brew, Roast | -| `map_with_legend_3(...)` | map_countries, map_max, label1-3, opacity1-3 | Cup | - -Detail page templates import with `{% import "partials/detail_cards.html" as detail %}` and call macros as `{{ detail::coffee_card(...) }}`. - -**View model helpers** — `presentation/web/views/mod.rs` provides: - -| Helper | Input | Purpose | -| ----------------------------- | ---------------- | --------------------------------------------------------------- | -| `build_coffee_info(roast)` | `&Roast` | Extracts origin, flag, region, producer, process, tasting notes | -| `build_roaster_info(roaster)` | `&Roaster` | Extracts country, flag, city, homepage | -| `build_map_data(entries)` | `&[(&str, u32)]` | Builds `data-countries` + `data-max` for `` | - -Each `*DetailView::from_parts()` calls these helpers and flattens the results into its own struct (Askama needs direct field access). - -**Detail page layout** — pages using the shared macros (Bag, Brew, Cup, Roast) follow the same grid structure: - -1. Header: page title + subtitle -2. Row 1 (2-col): Coffee card + Map with legend -3. Row 2 (2-col): Roaster card + page-specific card (Gear/Recipe for brew, Cafe for cup, Bag Info for bag) -4. Actions card (authenticated, full-width): page-specific buttons (e.g., Close Bag + Delete on bag page) - -Cafe, Gear, and Roaster detail pages have simpler standalone layouts. - -**Route handlers** — each handler fetches the entity with related data (bag → roast → roaster), builds the `*DetailView`, and renders the template. Handlers live in `application/routes/app/{entity}.rs`. - -### Macros Reference - -All macros have doc comments with usage examples. Check the source files for full documentation. - -| 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` | +All macros have doc comments. Key ones: `define_simple_service!`, `define_get_handler!`, `define_enriched_get_handler!`, `define_delete_handler!`, `define_list_fragment_renderer!`, `define_get_command!`, `define_delete_command!`, `push_update_field!`. Check source files for usage. ### SQL & Queries -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`. - -### Display Formatting - -`domain/formatting.rs` contains shared formatting helpers with unit tests. Always use these instead of ad-hoc `format!()` calls: - -| Function | Signature | Output examples | -| ---------------------- | --------------------------------------------------- | ----------------------------------------------------- | -| `format_relative_time` | `(dt: DateTime, now: DateTime) -> String` | "Just now", "5m ago", "Yesterday", "2w ago", "Mar 15" | -| `format_weight` | `(grams: f64) -> String` | "15g", "15.5g", "250g", "1.0kg", "2.3kg" | - -**`format_relative_time`** — accepts an explicit `now` parameter for testability. Callers pass `Utc::now()` at the call site. Covers seconds through absolute dates, with title case ("Just now", "Yesterday"). - -**`format_weight`** — displays grams up to 999g, switches to kg for 1000g+. Whole-gram values omit the decimal ("250g"), fractional values show one decimal ("15.5g"). Kilogram values always show one decimal ("1.5kg"). All weight values in the database are stored in grams. +Use `QueryBuilder` for dynamic queries, `push_update_field!` for UPDATEs. Sort method is `order_clause()` (not `sort_clause`). ### Stats Cache -Statistics are pre-computed and stored as a single JSON row in `stats_cache`. A background `tokio::spawn` task (`stats_recomputation_task` in `application/services/stats.rs`) recomputes all stats when signalled, with 2-second debouncing to collapse rapid mutations (e.g., bootstrap script). - -**`StatsInvalidator`** — lives on `AppState`, provides `invalidate()` which sends a non-blocking signal to the background task. Every entity create/update/delete handler must call `state.stats_invalidator.invalidate()` after a successful mutation. The `define_delete_handler!` macro does this automatically. - -**Stats page** (`application/routes/app/stats.rs`) — reads from cache via `stats_repo.get_cached()`, falling back to live computation on cache miss (first startup before background task completes). - -**Force recompute** — `POST /api/v1/stats/recompute` (authenticated) bypasses the debounce and recomputes synchronously. Available on the admin page. - -**Database reset** clears `stats_cache` along with all other coffee data (see `infrastructure/backup.rs`). +Stats are pre-computed in `stats_cache` via a background task with 2-second debouncing (`application/services/stats.rs`). **Every entity create/update/delete handler must call `state.stats_invalidator.invalidate()`** — the `define_delete_handler!` macro does this automatically. **Adding new stats:** 1. Add the field to the relevant domain struct (`RoastSummaryStats`, `ConsumptionStats`, `BrewingSummaryStats`, or `GeoStats`) 2. Add the query in `SqlStatsRepository` -3. The `CachedStats` struct inherits the change via serde -4. The stats page template can reference the new field — it will be populated from the cache +3. `CachedStats` inherits the change via serde +4. The stats page template can reference the new field immediately -**Gotcha:** If a new entity type is added that affects stats, its create/update/delete handlers must call `state.stats_invalidator.invalidate()`. +### Error Handling -### Error Handling & Logging +Error types: `RepositoryError` (domain), `AppError` (HTTP), `anyhow::Result` (CLI). Never silently discard errors — log before `map_err`, avoid bare `.ok()`, use `if let Err` instead of `let _ =`. Every create/update/delete logs at `info!` with entity ID. -**Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI). +### Open Graph -**Logging**: `tracing` + `tracing-subscriber` with `tower-http` `TraceLayer`. Configure via `RUST_LOG` (default `info`) and `RUST_LOG_FORMAT=json` for structured output. - -**Error logging rules** — never silently discard errors: - -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. - -**CRUD logging**: Every successful create/update/delete logs at `info!` with entity ID and key fields. - -**Security logging**: Auth events (login, logout, token create/revoke, passkey delete) log at `info!` with user ID. - -### Foursquare Integration - -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. +Base URL from `BREWLOG_RP_ORIGIN` via `crate::base_url()`. To add OG tags: add `pub base_url: &'static str` to template struct, override `{% block og_title %}`, `{% block og_description %}`, add og:image in `{% block head %}`. ## Datastar & Frontend ### Core Concepts -The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads. +Key Datastar attributes: -Key attributes: +| Attribute | Purpose | +| ---------------------------- | ---------------------------------------- | +| `data-signals:_name="value"` | Declare local signal (underscore prefix) | +| `data-show="$_signal"` | Conditional visibility | +| `data-bind:_signal-name` | Two-way binding to input | +| `data-on:event="expr"` | Event handler | +| `data-text="$_signal"` | Set text content from signal | +| `data-attr:attr="$_signal"` | Set attribute from signal | +| `@get/@post/@put/@delete` | HTTP actions with Datastar headers | -| 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 names: **kebab-case** in HTML (`data-signals:_roaster-name`), **camelCase** in JS (`$_roasterName`) and JSON (`_roasterName`). -### 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. +Two response formats: **HTML fragments** via `render_fragment(template, selector)`, **JSON signal patches** via `render_signals_json(&[("_signal-name", value)])` (pass kebab-case, auto-converts to camelCase). ### 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 +**Datastar**: visibility toggling, list CRUD, debounced search, AI extraction, multi-step wizards, searchable selects. +**JavaScript**: browser APIs (WebAuthn, clipboard, geolocation), infinite scroll, theme toggle, flows needing `window.location.reload()`. ### 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 - - - -
-
- -
- - -
- -
-
-``` - -Server-side handler: - -```rust -let signals = vec![ - ("_roaster-name", Value::String(result.name)), - ("_roaster-country", Value::String(result.country)), -]; -render_signals_json(&signals) -``` +Extraction forms use `@post` with `data-on:datastar-fetch` guarded by `_extracting` signal. Server returns `render_signals_json()` which Datastar merges into form fields via `data-bind`. See existing extraction forms for the template pattern. ### Web Components -**``** (`static/js/components/photo-capture.js`): - -| Attribute | Purpose | -| -------------- | ------------------------------------------------- | -| `target-input` | ID of hidden `` that receives the data URL | -| `target-form` | ID of `
` to submit after reading the photo | - -Clicking the element opens the camera/file picker, reads the file as a data URL, sets the target input, and submits the form. - -**``** (`static/js/components/searchable-select.js`): - -| Attribute | Purpose | -| ------------- | ------------------------------------------------------- | -| `name` | Name for hidden `` in form submission | -| `placeholder` | Search input placeholder (default: "Type to search...") | - -| Event | Detail | -| -------- | ----------------------------------------------- | -| `change` | `{ value, display, data }` — fires on selection | -| `clear` | Fires when selection is cleared | - -Place `