Files
homekeeper/CLAUDE.md
T
2026-07-06 19:25:38 +02:00

309 lines
12 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.md
Guidance for Claude Code when working in this repository.
## Platform Overview
**Homekeeper** — self-hosted platform for a *Selbstversorgerhof* (self-sufficiency farm). Multiple focused Shiny apps behind one reverse proxy, all sharing one PostgreSQL database.
**Owner:** Nico Friess (nico.friess@googlemail.com). UI language: German. Respond in German when Nico writes in German.
| App | Route | R package | Purpose |
|---|---|---|---|
| **Beekeeper** | `/beekeeper/` | `./beekeeper/` | Beehive management (Imkerei) |
| **Listkeeper** | `/listkeeper/` | `./listkeeper/` | Shopping lists, to-dos, projects |
Planned: Garten, Tiere, Haus & Hof.
All UI must work on mobile (Android). Bootstrap 5, no fixed pixel widths.
---
## Architecture
```
Browser
└── nginx (reverse proxy)
├── /beekeeper/ → Shiny :3838
├── /listkeeper/ → Shiny :3839
└── /api/ → FastAPI :8000
└── PostgreSQL :5432
```
**Shiny apps do not touch the database directly.** All data access goes through the FastAPI service via `httr2` (see API Client Pattern below). The `db_<entity>.R` files in each R package are thin wrappers around API calls — there is no DBI anywhere in the Shiny code.
**FastAPI** (`./api/`) handles schema creation (`init_db()` on startup), auth (HTTP Basic), and all SQL. It exposes a versioned REST API at `/v1/`.
---
## Local Development
```bash
# Start everything (builds images on first run)
docker compose up --build -d
# Useful
docker compose logs -f beekeeper
docker compose logs -f listkeeper
docker compose logs -f api
docker compose up -d --build beekeeper # rebuild one service
docker compose up -d --force-recreate nginx # reload nginx config without rebuild
docker compose down
# App logs (JSONL, written to host via volume)
tail -f beekeeper/logs/$(date +%Y-%m-%d)_log.jsonl
tail -f listkeeper/logs/$(date +%Y-%m-%d)_log.jsonl
```
Local URLs: `http://localhost/beekeeper/`, `http://localhost/listkeeper/`, `http://localhost/api/docs`
---
## Repository Structure
```
. ← repo root (will rename to homekeeper later)
├── docker-compose.yml
├── nginx.conf ← local reverse proxy; routes to Docker service names
├── pg_data/ ← PostgreSQL data (bind mount, gitignored)
├── www/ ← landing page served at /
│ └── index.html
├── api/ ← FastAPI service (Python)
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app/
│ ├── main.py ← lifespan, routers, root_path
│ ├── database.py ← engine, get_session, init_db()
│ ├── auth.py ← HTTP Basic auth, seed_users()
│ ├── models/
│ └── routers/ ← one file per resource
├── beekeeper/ ← R package
│ ├── Dockerfile
│ ├── DESCRIPTION ← version here
│ ├── NEWS.md
│ ├── logs/ ← JSONL logs (gitignored)
│ └── R/
├── listkeeper/ ← R package
│ ├── Dockerfile
│ ├── DESCRIPTION
│ ├── NEWS.md
│ ├── logs/
│ └── R/
└── infrastructure/ ← Ansible for Hetzner VM
├── site.yml
├── inventory/hosts.yml
├── group_vars/all.yml
└── roles/
├── common/ ← apt, ufw, fail2ban
├── podman/ ← install podman, auto-update timer
├── nginx/ ← system nginx + certbot (Let's Encrypt)
├── gitea/ ← Gitea as Podman Quadlet (git + container registry)
└── homekeeper/ ← app Quadlets (db, api, beekeeper, listkeeper)
```
---
## Production (Hetzner VM)
Target: Ubuntu 24.04, Podman + systemd Quadlets, system nginx.
| Domain | Purpose |
|---|---|
| `home.friessn.de` | Landing page + all apps |
| `git.friessn.de` | Gitea (git hosting + container registry) |
**Deployment flow:** push code → build images locally → `docker push git.friessn.de/nico/homekeeper-<app>:latest``podman auto-update` on VM pulls new image and restarts container (runs every 10s via systemd timer).
**Run the Ansible playbook:**
```bash
cd infrastructure
ansible-playbook -i inventory/hosts.yml site.yml
```
First-time setup has a Gitea bootstrap step — see `infrastructure/README.md`.
**Secrets:** all `CHANGE_ME` values in `group_vars/all.yml` must be set before first deploy. Use `ansible-vault encrypt_string` for passwords.
The local `docker-compose.yml` is for development only. Production uses Quadlet files in `infrastructure/roles/homekeeper/templates/`.
---
## Docker / Podman Services
| Service | Image | Port (internal) | Notes |
|---|---|---|---|
| `db` | `postgres:17` | 5432 | data in `./pg_data/` |
| `api` | built from `./api/` | 8000 | FastAPI, ROOT_PATH=/api |
| `beekeeper` | built from `./beekeeper/` | 3838 | Shiny |
| `listkeeper` | built from `./listkeeper/` | 3838 (local) / 3839 (prod) | Shiny, PORT env var |
| `nginx` | `nginx:alpine` | 80 | local only; system nginx on VM |
**ENV vars (all services read from docker-compose / Quadlet):**
- `API_URL`, `API_USER`, `API_PASS` — Shiny apps
- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH`, `INITIAL_USERS` — API
- `LOG_DIR` — Shiny apps (default `/logs`, mounted from `./*/logs/`)
- `PORT` — listkeeper only (default 3838)
---
## FastAPI (`./api/`)
Python 3.12, FastAPI + SQLAlchemy (raw SQL via `session.execute(text(...))`). No ORM queries — complex joins written by hand.
**Auth:** HTTP Basic. Users stored in `auth.users` (bcrypt). Seeded from `INITIAL_USERS` env var (`"user1:pass1,user2:pass2"`) on first startup.
**Adding an endpoint:** add a file to `app/routers/`, include it in `main.py`. All routes return `dict(r._mapping)` from raw SQL rows.
**Performance pattern:** avoid N+1 — use subqueries or JOINs to fetch related data in one query. Example: `colonies` list endpoint includes `last_inspection_date` as a subquery and `item_count`/`checked_count` in the lists endpoint.
---
## R Package Pattern
Plain Shiny — no golem, no rhino. Each package exports one function: `run_app()`.
**File naming:**
| File | Purpose |
|---|---|
| `app.R` | `run_app()` — calls `log_init()`, builds `api` list, starts `shinyApp()` |
| `ui.R` | `ui()``bslib::page(fillable=FALSE)` with sticky header |
| `server.R` | `server()` — initializes modules, navigation switch |
| `screen_<name>.R` | One module per screen: `screen_<name>_ui()` + `screen_<name>_server()` |
| `db_<entity>.R` | API wrapper functions — call `api_get/post/put/patch/delete()` |
| `api_client.R` | HTTP helpers: `api_get`, `api_get_one`, `api_post`, `api_put`, `api_patch`, `api_delete` |
| `logger.R` | `log_init()`, `log_info()`, `log_debug()`, `log_error()` — writes JSONL |
| `utils.R` | `%||%`, constants, badge helpers |
| `widgets.R` | Reusable UI components: `stepper_row`, `toggle_row`, `section_card`, etc. |
**`app.R` pattern:**
```r
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
log_init()
api <- list(
base_url = Sys.getenv("API_URL", "http://localhost:8000"),
user = Sys.getenv("API_USER", "homestead"),
pass = Sys.getenv("API_PASS", "homestead")
)
server_fn <- function(input, output, session) {
log_info("session_start", session_id = session$token)
session$onSessionEnded(function() log_info("session_end", session_id = session$token))
server(input, output, session, db = api)
}
shiny::runApp(shiny::shinyApp(ui(), server_fn), host = host, port = port, launch.browser = FALSE)
}
```
**Module pattern:**
```r
screen_example_ui <- function(id) {
ns <- NS(id)
shiny::tagList(...)
}
screen_example_server <- function(id, db, nav) {
moduleServer(id, function(input, output, session) {
ns <- session$ns
# db = api list (base_url, user, pass) — pass to api_get/post/etc.
# nav = reactiveValues(screen=, apiary_id=, colony_id=) in beekeeper
})
}
```
**API client pattern (`db_<entity>.R`):**
```r
db_colonies_list <- function(db, apiary_id = NULL, include_closed = FALSE) {
api_get(db, "/v1/colonies", list(apiary_id = apiary_id, include_closed = include_closed))
}
db_colonies_insert <- function(db, name, apiary_id, ...) {
result <- api_post(db, "/v1/colonies", list(name = name, apiary_id = apiary_id, ...))
result$id
}
```
**Navigation (beekeeper):** stack-based via `nav` reactiveValues. `nav$screen` drives a `switch()` in `output$screen_content`. Back navigation via `BACK` named vector. No `page_navbar()`.
**Beekeeper screens:**
`apiaries → colonies → colony → inspection / varroa / harvest / queens / checklists / actions / lineage`
Plus: `quick_inspection` (Schnelldurchsicht, all colonies at once) and `bulk_action` (Sammelmaßnahme, one action for many colonies).
**UI conventions:**
- `bslib::page(fillable = FALSE)` — scrollable
- Sticky header: `div(class = "bk-header d-flex ...")` with `position: sticky; top: 0`
- Tap cards: `div(class = "card tap-card mb-2", onclick = "Shiny.setInputValue(...)")`
- Bootstrap 5 only — no fixed pixel widths
- `section_card()`, `collapsible_card()`, `stepper_row()`, `toggle_row()` — use widgets from `widgets.R`
**Inline interaction (avoiding ID collisions in `lapply` loops):**
```r
# Buttons: onclick + Shiny.setInputValue instead of actionButton
shiny::tags$button(
onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", ns("select"), row$id)
)
# Text inputs: pass value via JS
shiny::tags$input(
onkeyup = sprintf(
"if(event.key==='Enter'){var v=this.value.trim();if(v){Shiny.setInputValue('%s',{list_id:%d,text:v,ts:Date.now()},{priority:'event'});this.value=''}}",
ns("add_item"), row$id
)
)
```
**Logging:**
```r
log_init() # once at startup — creates today's JSONL file
log_info("msg", k = v) # structured fields as named args
log_debug("msg", ...)
log_error("msg", ...)
```
Log files: `./beekeeper/logs/YYYY-MM-DD_log.jsonl` (host), `/logs/` inside container.
**Versioning:** bump `DESCRIPTION` and add an entry to `NEWS.md` when something meaningful ships. Minor bump (0.x.0) for features, patch (0.0.x) for fixes.
---
## Database Schemas
### `public` — Beekeeper
**`apiaries`**: `id, name, location_text, lat, lng, notes, created_at`
**`colonies`**: `id, apiary_id, name, status, parent_colony_id, current_queen_id, created_at, closed_at`
- `status`: `wirtschaftsvolk | ableger | jungvolk | schwarmstimmung | eingegangen`
- `closed_at IS NULL` = active
**`queens`**: `id, colony_id, year, breed, origin_colony_id, marked_color, notes, introduced_at, superseded_at`
**`inspections`**: `id, colony_id, date, time, inspector, brood_frames, honey_frames, food_stores_kg, colony_strength, queen_seen, eggs_seen, queen_cells_count, notes`
**`inspection_traits`**: `inspection_id, trait, score` — trait: `sanftmut|schwarmtrieb|wabensitz|honigertrag|varroa_hygiene` (14)
**`varroa_counts`**: `id, colony_id, date, time, date_from, method, count, notes` — method: `natuerlicher_totenfall|alkoholwaesche|puderzucker`
**`varroa_treatments`**: `id, colony_id, date, time, product, method, dosage, notes`
**`varroa_controls`**: `id, treatment_id, colony_id, date, result, notes`
**`honey_harvests`**: `id, colony_id, apiary_id, date, time, weight_kg, type, notes`
**`colony_actions`**: `id, colony_id, date, time, type, notes, related_colony_id, created_at`
- `type` is free text. Used values: `ablegerbildung | zwischenbodenableger | brutentnahme | standwechsel | futterung | vereinigung | mittelwand | sonstig`
**`checklists` / `checklist_items` / `checklist_completions`**
Colony log: API endpoint `GET /v1/colonies/{id}/log` — UNION ALL across all event tables, returns `date, type, summary, ref_id, created_at`.
### `lk` — Listkeeper
**`lk.lists`**: `id, name, type, t_shirt_size, status, notes, created_at, closed_at`
- `type`: `shopping | todo | project`
- `t_shirt_size`: `XS|S|M|L|XL` (projects only)
- `closed_at IS NULL` = active
**`lk.list_items`**: `id, list_id, text, checked, position, created_at`
List endpoint returns `item_count` and `checked_count` as aggregated columns (not from a separate query).
### `auth` — API users
**`auth.users`**: `id, username, hashed_password, is_active, created_at` — bcrypt, seeded from `INITIAL_USERS` env var.