brewlog/CLAUDE.md
2026-02-09 13:40:05 +00:00

971 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Claude Code Guidelines for Brewlog
## 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
## Build & Test Commands
Use `prek` to run the lints, tests and formatters all-in-one:
```bash
prek run -av
```
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 <migration_name> # Creates migrations/NNNN_<migration_name>.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
```
## Architecture
The codebase follows **Clean Architecture / Domain-Driven Design** with four layers:
```
src/
├── domain/ # Pure business logic, no external dependencies
│ ├── errors.rs # RepositoryError enum
│ ├── ids.rs # Typed ID wrappers (RoasterId, RoastId, BagId, BrewId, GearId, CafeId, CupId, 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
│ ├── coffee/ # roasters, roasts, bags, brews, cups, gear, cafes
│ ├── auth/ # users, sessions, tokens, passkey_credentials, 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)
│ ├── client/ # HTTP client for CLI
│ ├── ai.rs # OpenRouter LLM integration for AI extraction
│ ├── foursquare.rs # Foursquare Places API for nearby cafe search
│ ├── backup.rs # Database backup/restore
│ ├── webauthn.rs # WebAuthn/passkey credential storage
│ └── database.rs # Database pool abstraction
├── 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)
│ └── errors.rs # HTTP error mapping
└── presentation/ # User interfaces
├── cli/ # CLI commands and argument parsing
└── web/ # View models for templates
```
**Dependency flow**: `presentation → application → domain ← infrastructure`
## 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
<form
data-on:submit="$_extracting = true; @post(...)"
data-on:datastar-fetch="if (!$_extracting) return;
if (evt.detail.type === 'finished') { $_extracting = false }
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Failed.' }"
></form>
```
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 `<section>`, not nested inside it. Placing it inside removes the flex gap between form and list.
**5. Table wrapper must be `<section>`, not `<div>`.** List partials wrap the table in `<section class="rounded-lg border bg-surface">`.
**6. Infinite scroll sentinel needs `md:hidden`.** The sentinel `<div class="infinite-scroll-sentinel h-4 md:hidden">` 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 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.** 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.** 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.** 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`.
## 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<String>`). 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 `<meta property="og:image" content="{{ base_url }}/static/og-image.png" />` 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`.
### Service Layer
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.
- **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");
}
```
### Route Module Structure
Each list-bearing route module (roasters, roasts, bags, brews, cups, cafes, gear) follows the same structure:
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
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
}
```
### Detail Pages
Seven detail pages share layout via extracted template macros and Rust helpers:
| 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 |
**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 `<world-map>` |
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` |
### 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<Utc>, now: DateTime<Utc>) -> 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.
### 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`).
**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
**Gotcha:** If a new entity type is added that affects stats, its create/update/delete handlers must call `state.stats_invalidator.invalidate()`.
### Error Handling & Logging
**Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI).
**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.
## Datastar & Frontend
### Core Concepts
The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads.
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 — `<searchable-select>` with `data-on:change`
**Use JavaScript for:**
- Browser APIs: WebAuthn, clipboard, geolocation, FileReader
- Infinite scroll (`IntersectionObserver` in `base.html`)
- Theme toggle — must run in `<head>` 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
<section
data-signals:_extracting="false"
data-signals:_extract-error="''"
data-signals:_submitting="false"
>
<!-- Extraction form -->
<form
id="{id}-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; @post('{endpoint}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return;
if (evt.detail.type === 'finished') { $_extracting = false }
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed.' }"
>
<input type="hidden" name="image" id="{id}-image" />
<div data-show="!$_extracting">
<brew-photo-capture
target-input="{id}-image"
target-form="{id}-extract-form"
class="..."
>
Take Photo
</brew-photo-capture>
<input name="prompt" type="text" placeholder="Or describe..." />
<button type="submit">Go</button>
</div>
<div data-show="$_extracting" style="display:none"><!-- spinner --></div>
<p
data-show="$_extractError"
data-text="$_extractError"
style="display:none"
class="text-sm text-red-600"
></p>
</form>
<!-- Main form with data-bind fields populated by extraction -->
<form data-on:submit="$_submitting = true; @post(...)">
<input name="name" data-bind:_roaster-name class="input-field" />
</form>
</section>
```
Server-side handler:
```rust
let signals = vec![
("_roaster-name", Value::String(result.name)),
("_roaster-country", Value::String(result.country)),
];
render_signals_json(&signals)
```
### Web Components
**`<brew-photo-capture>`** (`static/js/components/photo-capture.js`):
| Attribute | Purpose |
| -------------- | ------------------------------------------------- |
| `target-input` | ID of hidden `<input>` that receives the data URL |
| `target-form` | ID of `<form>` 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.
**`<searchable-select>`** (`static/js/components/searchable-select.js`):
| Attribute | Purpose |
| ------------- | ------------------------------------------------------- |
| `name` | Name for hidden `<input>` 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 `<button>` children with `value` and `data-display` attributes. The component handles search filtering, hidden input, and selected-value display internally.
**`<chip-scroll>`** (`static/js/components/chip-scroll.js`):
Wraps a horizontal scroll container with floating left/right chevron buttons on desktop. Buttons auto-show/hide based on scroll position and are hidden on mobile (`max-width: 767px`). Uses `ResizeObserver` and `MutationObserver` to update when content changes (including Datastar morphs).
Required child structure:
| Selector | Purpose |
| --------------------- | ------------------------ |
| `[data-chip-scroll]` | The scrollable container |
| `[data-scroll-left]` | Left chevron button |
| `[data-scroll-right]` | Right chevron button |
Scroll distance and button sizing are set in the template `onclick` handler, not the component — use `scrollBy({left: ±200})` for chips, `±220` for cards. For chips use `p-1` + `h-4 w-4` icons at `left-0`/`right-0`. For cards use `p-1.5` + `h-5 w-5` icons at `-left-4`/`-right-4` (half-overlapping the card edges).
**`<world-map>`** (`static/js/components/world-map.js`):
Renders an inline SVG choropleth world map colored by country counts.
| Attribute | Purpose |
| ---------------- | ----------------------------------------------------- |
| `data-countries` | Comma-separated `ISO:count` pairs (e.g. `ET:5,CO:3`) |
| `data-max` | Maximum count value (for color scaling) |
| `data-selected` | ISO code of the currently selected country (optional) |
Uses `requestAnimationFrame` to defer rendering until after Datastar morphing completes.
**`<donut-chart>`** (`static/js/components/donut-chart.js`):
Renders an SVG donut chart with a legend, colored proportionally using the `--highlight-rgb` CSS custom property.
| Attribute | Purpose |
| ------------ | --------------------------------------------------------------- |
| `data-items` | Pipe-separated `label:count` pairs (e.g. `V60:15\|Aeropress:8`) |
| `data-icon` | Icon key displayed in the center (`"beaker"` or `"grinder"`) |
Re-renders on theme change via `MutationObserver` on the `data-theme` attribute. Uses `requestAnimationFrame` to defer rendering.
### FlexiblePayload
Handlers accept both JSON and form data via `FlexiblePayload<T>`. When form fields don't map 1:1 to the domain `New*` struct (e.g., a roaster name needing resolution to an ID), use a `*Submission` newtype that handles the conversion.
## Design System
### CSS Build
**Tailwind CSS v4** built via standalone CLI (no Node.js). `build.rs` runs `tailwindcss` automatically during `cargo build` — no manual step needed. Source: `static/css/input.css`. Output: `static/css/styles.css` (gitignored).
### Design Tokens
Colors are defined as CSS custom properties in `static/css/input.css` (`:root` for light, `[data-theme="dark"]` for dark) and mapped to Tailwind utilities via `@theme`. See `input.css` for exact colour values.
| Token | Tailwind classes |
| ------------------ | ------------------------------------------ |
| `--page` | `bg-page` |
| `--surface` | `bg-surface` |
| `--surface-alt` | `bg-surface-alt` |
| `--border` | default `border` / `divide-y` |
| `--accent` | `bg-accent`, `text-accent` |
| `--accent-hover` | `bg-accent-hover`, `hover:bg-accent-hover` |
| `--accent-subtle` | `bg-accent-subtle` |
| `--accent-text` | `text-accent-text` |
| `--text` | `text-text` |
| `--text-secondary` | `text-text-secondary` |
| `--text-muted` | `text-text-muted` |
Dark mode uses `[data-theme="dark"]` on `<html>`, set via a `<script>` in `<head>` that reads `localStorage` / `prefers-color-scheme` before body render. The `dark:` Tailwind prefix is available as an escape hatch.
### Component Classes
Defined in `input.css` — use these instead of ad-hoc utility combinations:
| Class | Use for |
| ------------------------------------ | ------------------------------------------------- |
| `.input-field` | All text/number/select inputs |
| `.btn-adjust` | +/- stepper buttons flanking a numeric input |
| `.sticky-submit` | Mobile-fixed / desktop-static submit bar |
| `.pill` + variant | Tags, status badges (always include `.pill` base) |
| `.tab` / `.tab-active` | Desktop tab buttons |
| `.tab-mobile` / `.tab-mobile-active` | Mobile tab dropdown items |
| `.responsive-table` | Tables that become card layout on mobile |
| `.scrollbar-hide` | Hide scrollbar on horizontal scroll containers |
| `.timeline-*` / `.tl-card` | Timeline page layout (line, node, heading, card) |
| `.text-2xs` | Micro text (0.65rem) — available for fine print |
| `.small-caps` | Font-variant small-caps — table headers, footer |
**Pill variants** (always pair with `.pill` base class):
| Variant | Use |
| -------------------------------------- | ------------------------------ |
| `.pill-muted` | Categories, neutral status |
| `.pill-success` | Positive status ("Open") |
| `.pill-warning` | Warning status |
| `.pill-floral` through `.pill-vegetal` | Tasting note SCA wheel colours |
Do not override pill colour/border/padding with utilities.
### UI Patterns
#### Page Layout
Main container from `base.html`: `mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-4 md:py-10`. All page sections are direct children spaced by `gap-8`.
Cards use `rounded-lg border bg-surface` — no shadows. Padding varies by context:
- `p-4` — compact cards (brew cards, bag cards, stat cards)
- `p-5` — form sections, admin sections, timeline cards
- `p-6` — auth pages (login, register)
**Clickable cards** (homepage cards, bag cards, brew cards, stats/data links) use `hover:border-accent/40` for hover state — never `hover:bg-surface-alt`. When a clickable card contains action buttons, wrap the buttons in `<div class="relative z-10">` and use `event.preventDefault()` on clicks to prevent the card link from firing.
#### Typography
| Level | Classes | Use |
| ---------------- | --------------------------------------------------------------- | ------------------------------------------------- |
| Page title | `text-3xl font-semibold` | `<h1>` on each page |
| Auth title | `text-2xl font-semibold text-accent` | Login/register headers |
| Section title | `text-lg font-semibold text-text` | `<h2>` for page sections |
| Subsection title | `text-base font-semibold text-text` | `<h3>` within cards |
| Form subsection | `text-sm font-semibold text-text` | `<h4>` grouping fields (e.g. "Coffee", "Grinder") |
| Form field label | `text-xs font-semibold text-text-muted uppercase tracking-wide` | `<h4>` / field group headings |
| Body | `text-sm text-text-secondary` | Description text |
| Muted | `text-xs text-text-muted` | Metadata, subtext |
| Micro | `text-2xs text-text-muted` | Fine print (available utility, currently unused) |
Page headers: `<header class="flex flex-col gap-2">` with `<h1>` + `<p class="max-w-2xl text-sm text-text-secondary">`.
**Font weight rules:**
- `font-bold` — stat values only (`text-lg font-bold text-text`)
- `font-semibold` — headings and primary action buttons
- `font-medium` — card action buttons, link-style actions, tab labels, table content, outlined/secondary buttons
**Small-caps pattern:** `text-xs uppercase tracking-wide` — used for form field labels, timeline kind labels. Table headers and the footer use the `.small-caps` utility class.
#### Icons
Entity-type icon mapping (consistent across all pages):
| Entity | Icon macro |
| ------- | -------------------- |
| brew | `icons::beaker` |
| roast | `icons::coffee_bean` |
| roaster | `icons::fire` |
| bag | `icons::bag` |
| cup | `icons::cup` |
| cafe | `icons::location` |
| gear | `icons::grinder` |
**Size by context:**
| Size | Context |
| --------- | ------------------------------------------------------------------------------------------------------------ |
| `h-3 w-3` | Timeline card category labels (inline with `text-xs`) |
| `h-4 w-4` | Buttons with text, tab buttons, admin page actions, list action links, form indicator icons |
| `h-5 w-5` | Nav icons, quick action buttons, loading spinners, homepage activity rows, timeline expand/collapse chevrons |
| `h-6 w-6` | Stat cards on homepage |
Always include `shrink-0` on icons inside flex containers to prevent shrinking.
When an icon appears alongside text in a button or label, use `inline-flex items-center gap-N` on the container:
- `gap-1` — compact inline labels (timeline categories)
- `gap-1.5` — card action buttons, tab buttons (mobile selected label)
- `gap-2` — standard buttons with icons
#### Buttons
| Variant | Classes | Use |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Primary | `inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover` | Form submits (Save, Log Brew, Check In), New Backup |
| Outlined | `inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition hover:bg-surface-alt` | Bordered secondary actions. Colour variants: `text-text` for Cancel/Back, `text-accent hover:text-text` for actions (Restore, Reset, Sign Out, Delete, Revoke) |
| Card action | `inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt` | Compact inline card buttons (e.g., Brew on bag card) |
| Link | `inline-flex items-center gap-1 text-sm font-medium` | Borderless inline actions. Colour variants: `text-accent hover:text-accent-hover` for actions (View all, Brew Again, Homepage), `text-text-muted hover:text-red-600` for destructive (Delete) |
| Text-only | `text-xs text-text-muted hover:text-text` | Minimal buttons: Change, Back in summary bars |
| Nav icon | `rounded-md p-1.5 text-text-muted transition hover:text-text-secondary` | Theme toggle, user menu |
| Adjustment | `.btn-adjust` CSS class | +/- steppers |
**Sizing rules:**
- `w-full` for full-width CTAs (form submits)
- `py-3` for larger touch targets (login, register, check-in submit)
- `disabled:opacity-50 disabled:cursor-not-allowed` with `data-attr:disabled` for loading states
Submit buttons right-aligned: `<div class="flex items-center justify-end gap-2">`. Outlined comes first when paired with Primary.
#### Forms
Label + input pattern:
```html
<label class="flex flex-col gap-1 text-sm">
<span class="text-text">Field Name *</span>
<input
type="text"
name="field"
required
class="input-field"
placeholder="Example"
/>
</label>
```
Mark required fields with `*`. Multi-column: `<div class="grid gap-4 sm:grid-cols-2">`. Form card wrapper: `<div class="rounded-lg border bg-surface p-5">` with `flex flex-col gap-4` (or `gap-6` with section headers).
Checkbox: `<label class="inline-flex items-center gap-2 text-sm cursor-pointer">` with `<input class="accent-orange-700" />`.
#### Feedback States
| Variant | Classes |
| --------------- | ------------------------------------------------------------------------------------------------------- |
| Error text | `text-sm text-red-600` with `data-show`/`data-text` bound to error signal |
| Error alert | `rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800` |
| Success alert | `rounded-md bg-green-100 border border-green-300 p-4 text-sm text-green-800` |
| Warning alert | `rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800` |
| Info alert | `rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800` |
| Loading spinner | `flex items-center gap-3 text-sm text-accent` with `{{ icons::spinner("h-5 w-5") }}` + `Saving&hellip;` |
Always pair loading spinners with `data-show` bound to an in-progress signal.
#### Empty & Prerequisite States
When a form requires a parent entity that doesn't exist yet, show a static message with `text-lg font-semibold text-text` heading and `text-sm text-text-secondary` body.
Homepage empty states use blurred placeholder cards with an overlay: `blur-[2px] select-none pointer-events-none` on the placeholder grid, `absolute inset-0 z-10 flex items-center justify-center` on the overlay with `text-lg font-semibold text-text-muted` label.
#### Selected Item Indicator
In multi-step flows, show: `rounded-lg border bg-surface px-4 py-3 flex items-center justify-between` — icon + name + muted subtext on left, text-only "Change" button (`text-xs text-text-muted hover:text-text`) on right.
#### Navigation
Active state: `text-accent font-medium`. Inactive: `text-text-muted hover:text-text transition`. Desktop nav: `hidden md:flex items-center gap-6`. Mobile nav: toggled via `data-show`.
#### Tabs
Tabs are used on `/data` and `/add` pages. Each tab button shows an entity-type icon + label.
Desktop: `<nav class="hidden md:flex gap-1.5">` with `.tab` buttons. Active state via `data-class:tab-active`.
Mobile: dropdown selector — trigger button shows current tab with chevron, options list uses `.tab-mobile` with `data-class:tab-mobile-active`.
Tab keys differ between pages: singular on `/add` (brew, roast, bag) vs plural on `/data` (brews, roasts, bags). Icon conditionals handle both: `tab.key == "brew" || tab.key == "brews"`.
#### Responsive Patterns
- Desktop-only: `hidden md:flex` or `hidden md:block`
- Mobile-only: `md:hidden`
- Pagination: `hidden md:flex` (desktop); infinite scroll (mobile)
#### Spacing
| Context | Gap |
| --------------------- | -------------------- |
| Page sections (main) | `gap-8` |
| Major form sections | `gap-6` |
| Related field groups | `gap-4` |
| Button groups | `gap-2` or `gap-3` |
| Icon + text (buttons) | `gap-2` |
| Icon + text (compact) | `gap-1` or `gap-1.5` |
| Label to input | `gap-1` |
| Navigation links | `gap-6` |
## Tables & Lists
### Page Template Structure
Each list page has form and list as **separate siblings** under `<main>` (which uses `flex flex-col gap-6`):
```html
<section data-signals:_show-form="false">
<header>...</header>
{% if is_authenticated %}
<div data-show="$_showForm" style="display: none"><!-- Form --></div>
{% endif %}
</section>
{% include "partials/lists/{entity}_list.html" %} {% endblock %}
```
### List Partial Structure
List partials live in `templates/partials/lists/`. Each follows:
```html
{% import "partials/lists/table.html" as table %}
<div id="{entity}-list" class="mt-6" data-star-scope="{entity}">
{% if items.is_empty() && !navigator.has_search() %}
<div
class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary"
>
<p class="text-center">No {entities} recorded yet.</p>
</div>
{% else %}
<section class="rounded-lg border bg-surface" ...>
{% call table::search_header(navigator, "#{entity}-list") %}
<table class="responsive-table ...">
...
</table>
{% if items.is_empty() %}
<div class="p-8 text-center text-text-muted">
No {entities} match your search.
</div>
{% endif %} {% call table::pagination_header(items, navigator,
"#{entity}-list") %} {% if items.has_next() %}
<div
class="infinite-scroll-sentinel h-4 md:hidden"
aria-hidden="true"
></div>
{% endif %}
</section>
{% endif %}
</div>
```
**Exception**: the bags partial uses a dual-section layout (open-bag cards + history table).
### Table Macros
Three macros in `templates/partials/lists/table.html`:
- **`search_header(navigator, target_selector)`** — search input with debounced `@get` + `history.pushState`
- **`pagination_header(items, navigator, target_selector)`** — prev/next, page count, rows-per-page; hidden on mobile via `pagination-controls hidden md:flex`
- **`sortable_header(label, key, navigator, target_selector)`** — clickable column header with sort arrows
### "Added" Column
Every table has a sortable "Added" column as its **first column** (sorted by `created-at`), styled with `text-xs font-medium text-text-secondary` to visually separate from content columns.
### Actions Column
When a row has multiple action buttons, wrap them in `<div class="inline-flex items-center gap-1">` inside the `<td>` to prevent vertical stacking.
### Responsive Table Pattern
Tables use the `responsive-table` CSS class (card layout on mobile, standard table on desktop).
**Desktop** — combine related fields with subtext:
```html
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
<div class="font-medium">{{ brew.roast_name }}</div>
<div class="hidden md:block text-xs text-text-muted">
{{ brew.roaster_name }}
</div>
</td>
```
**Mobile** — separate `<td>` for each sub-field:
```html
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">
{{ brew.roaster_name }}
</td>
```
### Pagination & Infinite Scroll
- **Desktop** (`md:+`): pagination controls via `pagination_header` macro
- **Mobile** (below `md:`): infinite scroll via `IntersectionObserver` in `base.html`, activated only on mobile via `matchMedia("(max-width: 767px)")`
### Search
Server-side via `q` query parameter. `ListQuery` extracts it, repos apply `LIKE` filtering. `ListNavigator` preserves the search term across pagination and sort URLs.
`ListNavigator` URL helpers: `page_href()` (full page), `fragment_page_href()` (with anchor), `sort_href()`.
## Code Style
### Rust
**Extract large closures** — if a closure in `.filter_map()`, `.map()`, etc. exceeds ~10 lines, extract into a named function.
**DRY repeated blocks** — when 3+ blocks follow the same structure differing only by a parameter, extract a helper.
**Prefer `match` over `if/else-if`** when branching on the same variable.
**Extract shared predicates** — if the same boolean condition appears in multiple functions, make it a named helper.
**Use generic helpers** — when the same decode/encode/match pattern appears 3+ times with different types.
### JavaScript
- **Never use `var`** — always `const` (default) or `let` (when reassignment needed)
- **Never use `function` declarations** — always arrow functions: `const fn = () => { ... }`
- **Always use template literals** for interpolation — never `+` concatenation
- Prefer `if`/`else` and `switch` over ternaries; never nest ternaries
- **Inline `onclick` + global arrow functions** for pages with imperative JS — do not use `DOMContentLoaded` + `addEventListener`
### Copy & UI Text
- **No second-person pronouns** — never use "you", "your", "you're" in user-facing strings (templates, CLI output, error messages, docs). Use imperative tone or impersonal phrasing instead.
- "Browse all your coffee data" → "Browse all coffee data"
- "Lets you sign in" → "Enables sign-in"
- "Your browser does not support" → "This browser does not support"
- "you won't see it again" → "it will not be shown again"
### Naming & Conventions
- **Method naming**: `order_clause()` for sort query builders (not `sort_clause`)
- **Imports**: Group by `super::`, then `crate::`, macros imported explicitly
- **SQL strings**: Use raw strings `r#"..."#` for multi-line queries
- **Tests**: Integration tests in `tests/cli/` and `tests/server/`. External APIs mocked with `wiremock`. See [Test Macros](#test-macros) below
- **Commits**: Conventional Commit format (`feat:`, `fix:`, `refactor:`, etc.)
- **Commit authorship**: Never add "Co-Authored-By" trailers
- **Commit signing**: Never use `--no-gpg-sign` — always allow default GPG signing
- **Committing**: Never commit unless explicitly prompted — provide a draft commit message instead
### Test Macros
Repeated test patterns are generated via macros in `tests/server/test_macros.rs` and `tests/cli/test_macros.rs`. Use these instead of hand-writing boilerplate tests.
**Server API tests** (`tests/server/test_macros.rs`):
`define_crud_tests!` — generates nonexistent-GET-404, nonexistent-DELETE-404, empty-list-200, and optionally malformed-JSON-400 and missing-fields-400:
```rust
use crate::test_macros::define_crud_tests;
define_crud_tests!(
entity: roaster, path: "/roasters", list_type: Roaster,
malformed_json: r#"{"name": "Test", "country": }"#,
missing_fields: r#"{"name": "Test Roasters"}"#
);
```
`define_datastar_entity_tests!` — generates list-with-fragment, list-without-full-page, and delete-with-fragment tests. Requires a setup function that creates an entity and returns its ID as `String`:
```rust
use crate::test_macros::define_datastar_entity_tests;
define_datastar_entity_tests!(
entity: roasters, type_param: "roasters", api_path: "/roasters",
list_element: r#"id="roaster-list""#, selector: "#roaster-list",
setup: create_roaster_entity
);
```
**CLI tests** (`tests/cli/test_macros.rs`):
`define_cli_auth_test!` — asserts a command fails without a token:
```rust
define_cli_auth_test!(test_add_roaster_requires_authentication,
&["roaster", "add", "--name", "Test", "--country", "UK"]);
```
`define_cli_list_test!` — asserts a list command succeeds without auth and returns a JSON array:
```rust
define_cli_list_test!(test_list_roasters_works_without_authentication,
&["roaster", "list"]);
```
**Helper generics**: `create_entity<P, R>()` in `tests/server/helpers.rs` (POST + auth + deserialize) and `create_entity_cli()` in `tests/cli/helpers.rs` (run + assert + parse ID) eliminate duplication in entity creation helpers.
## Communication Style
- Be direct and factual
- Analyse root causes before proposing solutions
- Prefer simple solutions over complex ones
- When proposing changes, explain the trade-offs