add homekeeper stack

This commit is contained in:
2026-07-06 19:25:38 +02:00
commit 58983e6855
90 changed files with 6816 additions and 0 deletions
+308
View File
@@ -0,0 +1,308 @@
# 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.