# 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_.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-: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_.R` | One module per screen: `screen__ui()` + `screen__server()` | | `db_.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_.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` (1–4) **`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.