build: add treefmt and pre-commit

This commit is contained in:
Jon Seager 2026-02-09 10:38:05 +00:00
parent 6493c6495a
commit 31dc620b0a
No known key found for this signature in database
6 changed files with 396 additions and 209 deletions

15
.gitignore vendored
View file

@ -1,10 +1,21 @@
# Build artifacts
target/ target/
result* result*
# SQLite database files
*.db *.db
*.db-journal *.db-journal
*.db-shm *.db-shm
*.db-wal *.db-wal
TODO.md
backup.json # Runtime configuration and secrets
*.env *.env
# Generated by TailwindCSS CLI as part of `cargo build`
static/css/styles.css static/css/styles.css
# This is generated by the nix shell hook
.pre-commit-config.yaml
# Backup of database
backup.json

412
CLAUDE.md
View file

@ -11,11 +11,26 @@ Brewlog is a self-hosted coffee logging platform built in Rust. It provides:
## Build & Test Commands ## Build & Test Commands
Use `prek` to run the lints, tests and formatters all-in-one:
```bash ```bash
cargo build # Build the project prek run -av
cargo test # Run all tests ```
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 clippy --allow-dirty --fix # Lint and auto-fix
cargo fmt # Format code nix fmt # Format code
``` ```
### Database Migrations ### Database Migrations
@ -101,13 +116,15 @@ src/
These are non-obvious footguns that will cause bugs if missed. 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 ```html
<form data-on:submit="$_extracting = true; @post(...)" <form
data-on:submit="$_extracting = true; @post(...)"
data-on:datastar-fetch="if (!$_extracting) return; data-on:datastar-fetch="if (!$_extracting) return;
if (evt.detail.type === 'finished') { $_extracting = false } if (evt.detail.type === 'finished') { $_extracting = false }
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Failed.' }"> else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Failed.' }"
></form>
``` ```
Only reset state on `finished` or `error`, never unconditionally. 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: `infrastructure/database.rs` configures SQLite pragmas at connection time:
| Pragma | Value | Purpose | | Pragma | Value | Purpose |
|--------|-------|---------| | -------------- | -------- | -------------------------------- |
| `foreign_keys` | `ON` | Enforce FK constraints | | `foreign_keys` | `ON` | Enforce FK constraints |
| `journal_mode` | `WAL` | Concurrent reads during writes | | `journal_mode` | `WAL` | Concurrent reads during writes |
| `synchronous` | `NORMAL` | Faster writes (safe with WAL) | | `synchronous` | `NORMAL` | Faster writes (safe with WAL) |
| `cache_size` | `-8000` | 8 MB page cache | | `cache_size` | `-8000` | 8 MB page cache |
| `temp_store` | `MEMORY` | Temp tables in RAM | | `temp_store` | `MEMORY` | Temp tables in RAM |
| `busy_timeout` | `5000` | Wait up to 5s on lock contention | | `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. 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 %}`. **`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:** **Adding OG tags to a new page:**
1. Add `pub base_url: &'static str` to the template struct 1. Add `pub base_url: &'static str` to the template struct
2. Set `base_url: crate::base_url()` in the handler 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 %}` 3. Override `{% block og_title %}`, `{% block og_description %}`, and add `<meta property="og:image" content="{{ base_url }}/static/og-image.png" />` 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. Services in `application/services/` encapsulate "create entity + record timeline event" as a single operation.
**When to use services vs repos:** **When to use services vs repos:**
- **Services** — for `create()` (and `finish()` for bags). These record a timeline event after the insert. - **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. - **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: Entities needing enrichment are hand-written:
| Service | Extra repos | Why | | Service | Extra repos | Why |
|---------|-------------|-----| | -------------- | ---------------------------- | -------------------------------------------------------------------- |
| `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline | | `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline |
| `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline | | `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline |
| `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response | | `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response |
| `CupService` | — | `create()` enriches via `get_with_details()` for timeline | | `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: 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: Seven detail pages share layout via extracted template macros and Rust helpers:
| Page | Route | Shared macros used | | Page | Route | Shared macros used |
|------|-------|--------------------| | ------- | ---------------------------------------------- | -------------------------------------------- |
| Bag | `/bags/{id}` | coffee_card, roaster_card, map_with_legend_2 | | Bag | `/bags/{id}` | coffee_card, roaster_card, map_with_legend_2 |
| Brew | `/brews/{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 | | Cafe | `/cafes/{slug}` | map_with_legend_1 |
| Cup | `/cups/{id}` | coffee_card, roaster_card, map_with_legend_3 | | Cup | `/cups/{id}` | coffee_card, roaster_card, map_with_legend_3 |
| Gear | `/gear/{id}` | (standalone layout) | | Gear | `/gear/{id}` | (standalone layout) |
| Roast | `/roasters/{roaster_slug}/roasts/{roast_slug}` | coffee_card, roaster_card, map_with_legend_2 | | Roast | `/roasters/{roaster_slug}/roasts/{roast_slug}` | coffee_card, roaster_card, map_with_legend_2 |
| Roaster | `/roasters/{slug}` | map_with_legend_1 | | Roaster | `/roasters/{slug}` | map_with_legend_1 |
**Template macros** — `templates/partials/detail_cards.html` provides: **Template macros** — `templates/partials/detail_cards.html` provides:
| Macro | Parameters | Used by | | Macro | Parameters | Used by |
|-------|-----------|---------| | ------------------------ | --------------------------------------------------------------------------------------- | --------------------------- |
| `share_button()` | — | (defined, currently unused) | | `share_button()` | — | (defined, currently unused) |
| `share_script()` | — | (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 | | `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 | | `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_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_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 | | `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(...) }}`. 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: **View model helpers** — `presentation/web/views/mod.rs` provides:
| Helper | Input | Purpose | | Helper | Input | Purpose |
|--------|-------|---------| | ----------------------------- | ---------------- | --------------------------------------------------------------- |
| `build_coffee_info(roast)` | `&Roast` | Extracts origin, flag, region, producer, process, tasting notes | | `build_coffee_info(roast)` | `&Roast` | Extracts origin, flag, region, producer, process, tasting notes |
| `build_roaster_info(roaster)` | `&Roaster` | Extracts country, flag, city, homepage | | `build_roaster_info(roaster)` | `&Roaster` | Extracts country, flag, city, homepage |
| `build_map_data(entries)` | `&[(&str, u32)]` | Builds `data-countries` + `data-max` for `<world-map>` | | `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). 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: **Detail page layout** — pages using the shared macros (Bag, Brew, Cup, Roast) follow the same grid structure:
1. Header: page title + subtitle 1. Header: page title + subtitle
2. Row 1 (2-col): Coffee card + Map with legend 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) 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. All macros have doc comments with usage examples. Check the source files for full documentation.
| Macro | Location | Purpose | | Macro | Location | Purpose |
|-------|----------|---------| | -------------------------------- | --------------------------------------- | -------------------------------------------------- |
| `define_simple_service!` | `application/services/mod.rs` | Generate service struct with `create()` + timeline | | `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_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_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_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_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_get_command!` | `presentation/cli/macros.rs` | CLI get-entity command |
| `define_delete_command!` | `presentation/cli/macros.rs` | CLI delete-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` | | `push_update_field!` | `infrastructure/repositories/macros.rs` | Build dynamic UPDATE queries with `QueryBuilder` |
### SQL & Queries ### 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: `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<Utc>, now: DateTime<Utc>) -> String` | "Just now", "5m ago", "Yesterday", "2w ago", "Mar 15" | | `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_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_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`). **Database reset** clears `stats_cache` along with all other coffee data (see `infrastructure/backup.rs`).
**Adding new stats:** **Adding new stats:**
1. Add the field to the relevant domain struct (`RoastSummaryStats`, `ConsumptionStats`, `BrewingSummaryStats`, or `GeoStats`) 1. Add the field to the relevant domain struct (`RoastSummaryStats`, `ConsumptionStats`, `BrewingSummaryStats`, or `GeoStats`)
2. Add the query in `SqlStatsRepository` 2. Add the query in `SqlStatsRepository`
3. The `CachedStats` struct inherits the change via serde 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: Key attributes:
| Attribute | Purpose | Example | | Attribute | Purpose | Example |
|-----------|---------|---------| | ---------------------------- | ------------------------------------------------------ | -------------------------------------------------- |
| `data-signals:_name="value"` | Declare local signal (underscore = not sent to server) | `data-signals:_show-form="false"` | | `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-show="$_signal"` | Conditional visibility | `data-show="$_showForm"` |
| `data-bind:_signal-name` | Two-way binding to input value | `data-bind:_roaster-name` | | `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-on:event="expr"` | Event handler | `data-on:submit="$_submitting = true; @post(...)"` |
| `data-ref="_name"` | DOM element reference | `data-ref="_form"` | | `data-ref="_name"` | DOM element reference | `data-ref="_form"` |
| `data-text="$_signal"` | Set text content from signal | `data-text="$_cafeName"` | | `data-text="$_signal"` | Set text content from signal | `data-text="$_cafeName"` |
| `data-attr:attr="$_signal"` | Set attribute from signal | `data-attr:value="$_roastId"` | | `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'})` | | `@get/@post/@put/@delete` | HTTP actions with Datastar headers | `@post('/api/v1/roasters', {contentType: 'form'})` |
### Signal Naming ### Signal Naming
@ -372,12 +393,12 @@ Signal names use **kebab-case** in HTML attributes and auto-convert to **camelCa
**Naming conventions for common signals:** **Naming conventions for common signals:**
| Signal | Purpose | | Signal | Purpose |
|--------|---------| | --------------------------- | ---------------------------- |
| `_extracting` | AI extraction in progress | | `_extracting` | AI extraction in progress |
| `_submitting` | Form save/create in progress | | `_submitting` | Form save/create in progress |
| `_extract-error` / `_error` | Error message | | `_extract-error` / `_error` | Error message |
| `_show-{thing}` | Boolean visibility toggle | | `_show-{thing}` | Boolean visibility toggle |
### Response Types ### Response Types
@ -390,6 +411,7 @@ Two response formats exist for Datastar:
### Datastar vs JavaScript ### Datastar vs JavaScript
**Use Datastar for:** **Use Datastar for:**
- Visibility toggling (`data-show` + signals) - Visibility toggling (`data-show` + signals)
- List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render - List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render
- Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides` - Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides`
@ -398,6 +420,7 @@ Two response formats exist for Datastar:
- Searchable selection lists — `<searchable-select>` with `data-on:change` - Searchable selection lists — `<searchable-select>` with `data-on:change`
**Use JavaScript for:** **Use JavaScript for:**
- Browser APIs: WebAuthn, clipboard, geolocation, FileReader - Browser APIs: WebAuthn, clipboard, geolocation, FileReader
- Infinite scroll (`IntersectionObserver` in `base.html`) - Infinite scroll (`IntersectionObserver` in `base.html`)
- Theme toggle — must run in `<head>` before DOM renders - Theme toggle — must run in `<head>` before DOM renders
@ -410,13 +433,19 @@ Pages with AI-powered form filling use a Datastar-native pattern. Extraction end
Template structure: Template structure:
```html ```html
<section data-signals:_extracting="false" data-signals:_extract-error="''" data-signals:_submitting="false"> <section
data-signals:_extracting="false"
data-signals:_extract-error="''"
data-signals:_submitting="false"
>
<!-- Extraction form --> <!-- Extraction form -->
<form id="{id}-extract-form" <form
id="{id}-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; @post('{endpoint}', {contentType: 'form'})" data-on:submit="$_extracting = true; $_extractError = ''; @post('{endpoint}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return; data-on:datastar-fetch="if (!$_extracting) return;
if (evt.detail.type === 'finished') { $_extracting = false } if (evt.detail.type === 'finished') { $_extracting = false }
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed.' }"> else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed.' }"
>
<input type="hidden" name="image" id="{id}-image" /> <input type="hidden" name="image" id="{id}-image" />
<div data-show="!$_extracting"> <div data-show="!$_extracting">
<brew-photo-capture target-input="{id}-image" target-form="{id}-extract-form" class="..."> <brew-photo-capture target-input="{id}-image" target-form="{id}-extract-form" class="...">
@ -426,7 +455,12 @@ Template structure:
<button type="submit">Go</button> <button type="submit">Go</button>
</div> </div>
<div data-show="$_extracting" style="display:none"><!-- spinner --></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> <p
data-show="$_extractError"
data-text="$_extractError"
style="display:none"
class="text-sm text-red-600"
></p>
</form> </form>
<!-- Main form with data-bind fields populated by extraction --> <!-- Main form with data-bind fields populated by extraction -->
@ -450,24 +484,24 @@ render_signals_json(&signals)
**`<brew-photo-capture>`** (`static/js/components/photo-capture.js`): **`<brew-photo-capture>`** (`static/js/components/photo-capture.js`):
| Attribute | Purpose | | Attribute | Purpose |
|-----------|---------| | -------------- | ------------------------------------------------- |
| `target-input` | ID of hidden `<input>` that receives the data URL | | `target-input` | ID of hidden `<input>` that receives the data URL |
| `target-form` | ID of `<form>` to submit after reading the photo | | `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. 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`): **`<searchable-select>`** (`static/js/components/searchable-select.js`):
| Attribute | Purpose | | Attribute | Purpose |
|-----------|---------| | ------------- | ------------------------------------------------------- |
| `name` | Name for hidden `<input>` in form submission | | `name` | Name for hidden `<input>` in form submission |
| `placeholder` | Search input placeholder (default: "Type to search...") | | `placeholder` | Search input placeholder (default: "Type to search...") |
| Event | Detail | | Event | Detail |
|-------|--------| | -------- | ----------------------------------------------- |
| `change` | `{ value, display, data }` — fires on selection | | `change` | `{ value, display, data }` — fires on selection |
| `clear` | Fires when selection is cleared | | `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. Place `<button>` children with `value` and `data-display` attributes. The component handles search filtering, hidden input, and selected-value display internally.
@ -477,11 +511,11 @@ Wraps a horizontal scroll container with floating left/right chevron buttons on
Required child structure: Required child structure:
| Selector | Purpose | | Selector | Purpose |
|----------|---------| | --------------------- | ------------------------ |
| `[data-chip-scroll]` | The scrollable container | | `[data-chip-scroll]` | The scrollable container |
| `[data-scroll-left]` | Left chevron button | | `[data-scroll-left]` | Left chevron button |
| `[data-scroll-right]` | Right 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). 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).
@ -489,11 +523,11 @@ Scroll distance and button sizing are set in the template `onclick` handler, not
Renders an inline SVG choropleth world map colored by country counts. Renders an inline SVG choropleth world map colored by country counts.
| Attribute | Purpose | | Attribute | Purpose |
|-----------|---------| | ---------------- | ----------------------------------------------------- |
| `data-countries` | Comma-separated `ISO:count` pairs (e.g. `ET:5,CO:3`) | | `data-countries` | Comma-separated `ISO:count` pairs (e.g. `ET:5,CO:3`) |
| `data-max` | Maximum count value (for color scaling) | | `data-max` | Maximum count value (for color scaling) |
| `data-selected` | ISO code of the currently selected country (optional) | | `data-selected` | ISO code of the currently selected country (optional) |
Uses `requestAnimationFrame` to defer rendering until after Datastar morphing completes. Uses `requestAnimationFrame` to defer rendering until after Datastar morphing completes.
@ -501,10 +535,10 @@ Uses `requestAnimationFrame` to defer rendering until after Datastar morphing co
Renders an SVG donut chart with a legend, colored proportionally using the `--highlight-rgb` CSS custom property. Renders an SVG donut chart with a legend, colored proportionally using the `--highlight-rgb` CSS custom property.
| Attribute | Purpose | | Attribute | Purpose |
|-----------|---------| | ------------ | --------------------------------------------------------------- |
| `data-items` | Pipe-separated `label:count` pairs (e.g. `V60:15\|Aeropress:8`) | | `data-items` | Pipe-separated `label:count` pairs (e.g. `V60:15\|Aeropress:8`) |
| `data-icon` | Icon key displayed in the center (`"beaker"` or `"grinder"`) | | `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. Re-renders on theme change via `MutationObserver` on the `data-theme` attribute. Uses `requestAnimationFrame` to defer rendering.
@ -522,19 +556,19 @@ Handlers accept both JSON and form data via `FlexiblePayload<T>`. When form fiel
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. 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 | | Token | Tailwind classes |
|-------|-----------------| | ------------------ | ------------------------------------------ |
| `--page` | `bg-page` | | `--page` | `bg-page` |
| `--surface` | `bg-surface` | | `--surface` | `bg-surface` |
| `--surface-alt` | `bg-surface-alt` | | `--surface-alt` | `bg-surface-alt` |
| `--border` | default `border` / `divide-y` | | `--border` | default `border` / `divide-y` |
| `--accent` | `bg-accent`, `text-accent` | | `--accent` | `bg-accent`, `text-accent` |
| `--accent-hover` | `bg-accent-hover`, `hover:bg-accent-hover` | | `--accent-hover` | `bg-accent-hover`, `hover:bg-accent-hover` |
| `--accent-subtle` | `bg-accent-subtle` | | `--accent-subtle` | `bg-accent-subtle` |
| `--accent-text` | `text-accent-text` | | `--accent-text` | `text-accent-text` |
| `--text` | `text-text` | | `--text` | `text-text` |
| `--text-secondary` | `text-text-secondary` | | `--text-secondary` | `text-text-secondary` |
| `--text-muted` | `text-text-muted` | | `--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. 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.
@ -542,27 +576,27 @@ Dark mode uses `[data-theme="dark"]` on `<html>`, set via a `<script>` in `<head
Defined in `input.css` — use these instead of ad-hoc utility combinations: Defined in `input.css` — use these instead of ad-hoc utility combinations:
| Class | Use for | | Class | Use for |
|-------|---------| | ------------------------------------ | ------------------------------------------------- |
| `.input-field` | All text/number/select inputs | | `.input-field` | All text/number/select inputs |
| `.btn-adjust` | +/- stepper buttons flanking a numeric input | | `.btn-adjust` | +/- stepper buttons flanking a numeric input |
| `.sticky-submit` | Mobile-fixed / desktop-static submit bar | | `.sticky-submit` | Mobile-fixed / desktop-static submit bar |
| `.pill` + variant | Tags, status badges (always include `.pill` base) | | `.pill` + variant | Tags, status badges (always include `.pill` base) |
| `.tab` / `.tab-active` | Desktop tab buttons | | `.tab` / `.tab-active` | Desktop tab buttons |
| `.tab-mobile` / `.tab-mobile-active` | Mobile tab dropdown items | | `.tab-mobile` / `.tab-mobile-active` | Mobile tab dropdown items |
| `.responsive-table` | Tables that become card layout on mobile | | `.responsive-table` | Tables that become card layout on mobile |
| `.scrollbar-hide` | Hide scrollbar on horizontal scroll containers | | `.scrollbar-hide` | Hide scrollbar on horizontal scroll containers |
| `.timeline-*` / `.tl-card` | Timeline page layout (line, node, heading, card) | | `.timeline-*` / `.tl-card` | Timeline page layout (line, node, heading, card) |
| `.text-2xs` | Micro text (0.65rem) — available for fine print | | `.text-2xs` | Micro text (0.65rem) — available for fine print |
| `.small-caps` | Font-variant small-caps — table headers, footer | | `.small-caps` | Font-variant small-caps — table headers, footer |
**Pill variants** (always pair with `.pill` base class): **Pill variants** (always pair with `.pill` base class):
| Variant | Use | | Variant | Use |
|---------|-----| | -------------------------------------- | ------------------------------ |
| `.pill-muted` | Categories, neutral status | | `.pill-muted` | Categories, neutral status |
| `.pill-success` | Positive status ("Open") | | `.pill-success` | Positive status ("Open") |
| `.pill-warning` | Warning status | | `.pill-warning` | Warning status |
| `.pill-floral` through `.pill-vegetal` | Tasting note SCA wheel colours | | `.pill-floral` through `.pill-vegetal` | Tasting note SCA wheel colours |
Do not override pill colour/border/padding with utilities. Do not override pill colour/border/padding with utilities.
@ -574,6 +608,7 @@ Do not override pill colour/border/padding with utilities.
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`. 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: Cards use `rounded-lg border bg-surface` — no shadows. Padding varies by context:
- `p-4` — compact cards (brew cards, bag cards, stat cards) - `p-4` — compact cards (brew cards, bag cards, stat cards)
- `p-5` — form sections, admin sections, timeline cards - `p-5` — form sections, admin sections, timeline cards
- `p-6` — auth pages (login, register) - `p-6` — auth pages (login, register)
@ -582,21 +617,22 @@ Cards use `rounded-lg border bg-surface` — no shadows. Padding varies by conte
#### Typography #### Typography
| Level | Classes | Use | | Level | Classes | Use |
|-------|---------|-----| | ---------------- | --------------------------------------------------------------- | ------------------------------------------------- |
| Page title | `text-3xl font-semibold` | `<h1>` on each page | | Page title | `text-3xl font-semibold` | `<h1>` on each page |
| Auth title | `text-2xl font-semibold text-accent` | Login/register headers | | Auth title | `text-2xl font-semibold text-accent` | Login/register headers |
| Section title | `text-lg font-semibold text-text` | `<h2>` for page sections | | Section title | `text-lg font-semibold text-text` | `<h2>` for page sections |
| Subsection title | `text-base font-semibold text-text` | `<h3>` within cards | | 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 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 | | 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 | | Body | `text-sm text-text-secondary` | Description text |
| Muted | `text-xs text-text-muted` | Metadata, subtext | | Muted | `text-xs text-text-muted` | Metadata, subtext |
| Micro | `text-2xs text-text-muted` | Fine print (available utility, currently unused) | | 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">`. 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 weight rules:**
- `font-bold` — stat values only (`text-lg font-bold text-text`) - `font-bold` — stat values only (`text-lg font-bold text-text`)
- `font-semibold` — headings and primary action buttons - `font-semibold` — headings and primary action buttons
- `font-medium` — card action buttons, link-style actions, tab labels, table content, outlined/secondary buttons - `font-medium` — card action buttons, link-style actions, tab labels, table content, outlined/secondary buttons
@ -607,45 +643,47 @@ Page headers: `<header class="flex flex-col gap-2">` with `<h1>` + `<p class="ma
Entity-type icon mapping (consistent across all pages): Entity-type icon mapping (consistent across all pages):
| Entity | Icon macro | | Entity | Icon macro |
|--------|-----------| | ------- | -------------------- |
| brew | `icons::beaker` | | brew | `icons::beaker` |
| roast | `icons::coffee_bean` | | roast | `icons::coffee_bean` |
| roaster | `icons::fire` | | roaster | `icons::fire` |
| bag | `icons::bag` | | bag | `icons::bag` |
| cup | `icons::cup` | | cup | `icons::cup` |
| cafe | `icons::location` | | cafe | `icons::location` |
| gear | `icons::grinder` | | gear | `icons::grinder` |
**Size by context:** **Size by context:**
| Size | Context | | Size | Context |
|------|---------| | --------- | ------------------------------------------------------------------------------------------------------------ |
| `h-3 w-3` | Timeline card category labels (inline with `text-xs`) | | `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-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-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 | | `h-6 w-6` | Stat cards on homepage |
Always include `shrink-0` on icons inside flex containers to prevent shrinking. 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: 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` — compact inline labels (timeline categories)
- `gap-1.5` — card action buttons, tab buttons (mobile selected label) - `gap-1.5` — card action buttons, tab buttons (mobile selected label)
- `gap-2` — standard buttons with icons - `gap-2` — standard buttons with icons
#### Buttons #### Buttons
| Variant | Classes | Use | | 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 | | 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) | | 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) | | 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) | | 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 | | 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 | | 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 | | Adjustment | `.btn-adjust` CSS class | +/- steppers |
**Sizing rules:** **Sizing rules:**
- `w-full` for full-width CTAs (form submits) - `w-full` for full-width CTAs (form submits)
- `py-3` for larger touch targets (login, register, check-in submit) - `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 - `disabled:opacity-50 disabled:cursor-not-allowed` with `data-attr:disabled` for loading states
@ -669,13 +707,13 @@ Checkbox: `<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
#### Feedback States #### Feedback States
| Variant | Classes | | Variant | Classes |
|---------|---------| | --------------- | ------------------------------------------------------------------------------------------------------- |
| Error text | `text-sm text-red-600` with `data-show`/`data-text` bound to error signal | | 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` | | 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` | | 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` | | 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` | | 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;` | | 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. Always pair loading spinners with `data-show` bound to an in-progress signal.
@ -712,16 +750,16 @@ Tab keys differ between pages: singular on `/add` (brew, roast, bag) vs plural o
#### Spacing #### Spacing
| Context | Gap | | Context | Gap |
|---------|-----| | --------------------- | -------------------- |
| Page sections (main) | `gap-8` | | Page sections (main) | `gap-8` |
| Major form sections | `gap-6` | | Major form sections | `gap-6` |
| Related field groups | `gap-4` | | Related field groups | `gap-4` |
| Button groups | `gap-2` or `gap-3` | | Button groups | `gap-2` or `gap-3` |
| Icon + text (buttons) | `gap-2` | | Icon + text (buttons) | `gap-2` |
| Icon + text (compact) | `gap-1` or `gap-1.5` | | Icon + text (compact) | `gap-1` or `gap-1.5` |
| Label to input | `gap-1` | | Label to input | `gap-1` |
| Navigation links | `gap-6` | | Navigation links | `gap-6` |
## Tables & Lists ## Tables & Lists
@ -737,8 +775,7 @@ Each list page has form and list as **separate siblings** under `<main>` (which
{% endif %} {% endif %}
</section> </section>
{% include "partials/lists/{entity}_list.html" %} {% include "partials/lists/{entity}_list.html" %} {% endblock %}
{% endblock %}
``` ```
### List Partial Structure ### List Partial Structure
@ -756,12 +793,13 @@ List partials live in `templates/partials/lists/`. Each follows:
{% else %} {% else %}
<section class="rounded-lg border bg-surface" ...> <section class="rounded-lg border bg-surface" ...>
{% call table::search_header(navigator, "#{entity}-list") %} {% call table::search_header(navigator, "#{entity}-list") %}
<table class="responsive-table ...">...</table> <table class="responsive-table ...">
...
</table>
{% if items.is_empty() %} {% if items.is_empty() %}
<div class="p-8 text-center text-text-muted">No {entities} match your search.</div> <div class="p-8 text-center text-text-muted">No {entities} match your search.</div>
{% endif %} {% endif %} {% call table::pagination_header(items, navigator, "#{entity}-list") %} {% if
{% call table::pagination_header(items, navigator, "#{entity}-list") %} items.has_next() %}
{% if items.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
{% endif %} {% endif %}
</section> </section>
@ -803,9 +841,7 @@ Tables use the `responsive-table` CSS class (card layout on mobile, standard tab
**Mobile** — separate `<td>` for each sub-field: **Mobile** — separate `<td>` for each sub-field:
```html ```html
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden"> <td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.roaster_name }}</td>
{{ brew.roaster_name }}
</td>
``` ```
### Pagination & Infinite Scroll ### Pagination & Infinite Scroll

View file

@ -78,15 +78,15 @@ directory is loaded automatically via [dotenvy](https://crates.io/crates/dotenvy
### Server (`brewlog serve`) ### Server (`brewlog serve`)
| Variable | Purpose | Default | | Variable | Purpose | Default |
| ------------------------ | ------------------------------------------------------ | --------------------- | | -------------------------- | --------------------------------------------------------------------- | --------------------- |
| `BREWLOG_RP_ID` | WebAuthn Relying Party ID (server domain) | **required** | | `BREWLOG_RP_ID` | WebAuthn Relying Party ID (server domain) | **required** |
| `BREWLOG_RP_ORIGIN` | WebAuthn Relying Party origin (full URL) | **required** | | `BREWLOG_RP_ORIGIN` | WebAuthn Relying Party origin (full URL) | **required** |
| `BREWLOG_DATABASE_URL` | Database connection string | `sqlite://brewlog.db` | | `BREWLOG_DATABASE_URL` | Database connection string | `sqlite://brewlog.db` |
| `BREWLOG_BIND_ADDRESS` | Server bind address | `127.0.0.1:3000` | | `BREWLOG_BIND_ADDRESS` | Server bind address | `127.0.0.1:3000` |
| `BREWLOG_INSECURE_COOKIES` | Disable the `Secure` cookie flag (set `true` for local dev over HTTP) | `false` | | `BREWLOG_INSECURE_COOKIES` | Disable the `Secure` cookie flag (set `true` for local dev over HTTP) | `false` |
| `RUST_LOG` | Log level filter | `info` | | `RUST_LOG` | Log level filter | `info` |
| `RUST_LOG_FORMAT` | Set to `json` for structured log output | — | | `RUST_LOG_FORMAT` | Set to `json` for structured log output | — |
### CLI Client ### CLI Client

View file

@ -1,5 +1,21 @@
{ {
"nodes": { "nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": { "flake-parts": {
"inputs": { "inputs": {
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
@ -18,13 +34,56 @@
"type": "github" "type": "github"
} }
}, },
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1769939035,
"narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770197578, "lastModified": 1770562336,
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=", "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2", "rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -52,8 +111,10 @@
"root": { "root": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay",
"treefmt-nix": "treefmt-nix"
} }
}, },
"rust-overlay": { "rust-overlay": {
@ -63,11 +124,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1770347142, "lastModified": 1770606655,
"narHash": "sha256-uz+ZSqXpXEPtdRPYwvgsum/CfNq7AUQ/0gZHqTigiPM=", "narHash": "sha256-rpJf+kxvLWv32ivcgu8d+JeJooog3boJCT8J3joJvvM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "2859683cd9ef7858d324c5399b0d8d6652bf4044", "rev": "11a396520bf911e4ed01e78e11633d3fc63b350e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -75,6 +136,26 @@
"repo": "rust-overlay", "repo": "rust-overlay",
"type": "github" "type": "github"
} }
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1770228511,
"narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "337a4fe074be1042a35086f15481d763b8ddc0e7",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View file

@ -6,6 +6,12 @@
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
git-hooks.url = "github:cachix/git-hooks.nix";
git-hooks.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = outputs =
@ -14,6 +20,7 @@
nixpkgs, nixpkgs,
rust-overlay, rust-overlay,
flake-parts, flake-parts,
...
}@inputs: }@inputs:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ systems = [
@ -21,6 +28,11 @@
"aarch64-linux" "aarch64-linux"
]; ];
imports = [
inputs.treefmt-nix.flakeModule
inputs.git-hooks.flakeModule
];
perSystem = perSystem =
{ config, system, ... }: { config, system, ... }:
let let
@ -110,6 +122,10 @@
NIX_CONFIG = "experimental-features = nix-command flakes"; NIX_CONFIG = "experimental-features = nix-command flakes";
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library"; RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
shellHook = ''
${config.pre-commit.shellHook}
'';
buildInputs = [ buildInputs = [
rust rust
] ]
@ -125,7 +141,50 @@
sqlite sqlite
sqlx-cli sqlx-cli
tailwindcss_4 tailwindcss_4
]); ])
++ config.pre-commit.settings.enabledPackages;
};
};
treefmt = {
projectRootFile = "flake.nix";
programs = {
deadnix.enable = true;
nixfmt.enable = true;
rustfmt.enable = true;
shfmt.enable = true;
};
};
pre-commit = {
check.enable = false;
settings = {
package = pkgs.prek;
hooks = {
treefmt = {
enable = true;
package = config.treefmt.build.wrapper;
pass_filenames = false;
stages = [ "pre-commit" ];
};
clippy = {
enable = true;
package = rust;
packageOverrides = {
cargo = rust;
clippy = rust;
};
settings.extraArgs = "--allow-dirty --fix";
};
cargo-test = {
enable = true;
files = "\\.(rs|toml)$";
entry = "cargo test";
pass_filenames = false;
stages = [ "pre-commit" ];
};
shellcheck.enable = true;
};
}; };
}; };
}; };

View file

@ -9,7 +9,7 @@ cargo build
# 2. Register at the URL printed on first start # 2. Register at the URL printed on first start
# 3. Create a token: ./target/debug/brewlog token create --name "bootstrap-token" # 3. Create a token: ./target/debug/brewlog token create --name "bootstrap-token"
# 4. Export the token: export BREWLOG_TOKEN=<token> # 4. Export the token: export BREWLOG_TOKEN=<token>
if [[ -z "${BREWLOG_TOKEN:-}" ]]; then if [[ -z ${BREWLOG_TOKEN:-} ]]; then
echo "Error: BREWLOG_TOKEN environment variable is not set." echo "Error: BREWLOG_TOKEN environment variable is not set."
echo "Create a token first: ./target/debug/brewlog token create --name bootstrap-token" echo "Create a token first: ./target/debug/brewlog token create --name bootstrap-token"
exit 1 exit 1