From 31dc620b0a9646e5ac67114d5b15e6c17d19fedd Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 9 Feb 2026 10:38:05 +0000 Subject: [PATCH] build: add `treefmt` and `pre-commit` --- .gitignore | 17 +- CLAUDE.md | 412 ++++++++++++++++++++++------------------ README.md | 18 +- flake.lock | 95 ++++++++- flake.nix | 61 +++++- scripts/bootstrap-db.sh | 2 +- 6 files changed, 396 insertions(+), 209 deletions(-) diff --git a/.gitignore b/.gitignore index 3aa9c2e..6dc64cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,21 @@ +# Build artifacts target/ result* + +# SQLite database files *.db *.db-journal *.db-shm *.db-wal -TODO.md -backup.json + +# Runtime configuration and secrets *.env -static/css/styles.css \ No newline at end of file + +# Generated by TailwindCSS CLI as part of `cargo build` +static/css/styles.css + +# This is generated by the nix shell hook +.pre-commit-config.yaml + +# Backup of database +backup.json diff --git a/CLAUDE.md b/CLAUDE.md index 7c39769..e70d790 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,11 +11,26 @@ Brewlog is a self-hosted coffee logging platform built in Rust. It provides: ## Build & Test Commands +Use `prek` to run the lints, tests and formatters all-in-one: + ```bash -cargo build # Build the project -cargo test # Run all tests +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 -cargo fmt # Format code +nix fmt # Format code ``` ### Database Migrations @@ -101,13 +116,15 @@ 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.** 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 -
+ else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Failed.' }" +>
``` Only reset state on `finished` or `error`, never unconditionally. @@ -140,14 +157,14 @@ Only reset state on `finished` or `error`, never unconditionally. `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 | +| 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. @@ -164,6 +181,7 @@ Social media preview cards are powered by Open Graph and Twitter Card meta tags **`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 %}` @@ -177,6 +195,7 @@ All data access goes through trait-based repositories defined in `domain/reposit 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. @@ -186,12 +205,12 @@ The `define_simple_service!` macro in `services/mod.rs` generates services for e 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 | +| 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: @@ -226,41 +245,42 @@ if is_datastar_request(&headers) { 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 | +| 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 | +| 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 `` | +| 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) @@ -274,16 +294,16 @@ Cafe, Gear, and Roaster detail pages have simpler standalone layouts. 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` | +| 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 @@ -293,10 +313,10 @@ Use `QueryBuilder` for dynamic queries. For UPDATE, use `push_update_field!` (se `domain/formatting.rs` contains shared formatting helpers with unit tests. Always use these instead of ad-hoc `format!()` calls: -| Function | Signature | Output examples | -|----------|-----------|-----------------| +| 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_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"). @@ -315,6 +335,7 @@ Statistics are pre-computed and stored as a single JSON row in `stats_cache`. A **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 @@ -351,16 +372,16 @@ The web UI uses [Datastar](https://data-star.dev/) for reactive updates without 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'})` | +| 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 @@ -372,12 +393,12 @@ Signal names use **kebab-case** in HTML attributes and auto-convert to **camelCa **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 | +| 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 @@ -390,6 +411,7 @@ Two response formats exist for Datastar: ### 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` @@ -398,6 +420,7 @@ Two response formats exist for Datastar: - 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 @@ -410,13 +433,19 @@ Pages with AI-powered form filling use a Datastar-native pattern. Extraction end Template structure: ```html -
+
-
+ else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed.' }" + >
@@ -426,7 +455,12 @@ Template structure:
- +
@@ -450,24 +484,24 @@ render_signals_json(&signals) **``** (`static/js/components/photo-capture.js`): -| Attribute | Purpose | -|-----------|---------| +| Attribute | Purpose | +| -------------- | ------------------------------------------------- | | `target-input` | ID of hidden `` that receives the data URL | -| `target-form` | ID of `
` to submit after reading the photo | +| `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 | +| Attribute | Purpose | +| ------------- | ------------------------------------------------------- | +| `name` | Name for hidden `` in form submission | | `placeholder` | Search input placeholder (default: "Type to search...") | -| Event | Detail | -|-------|--------| +| Event | Detail | +| -------- | ----------------------------------------------- | | `change` | `{ value, display, data }` — fires on selection | -| `clear` | Fires when selection is cleared | +| `clear` | Fires when selection is cleared | Place `