12 KiB
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
# 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:
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 appsDB_HOST/PORT/NAME/USER/PASSWORD,ROOT_PATH,INITIAL_USERS— APILOG_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 |
`% |
widgets.R |
Reusable UI components: stepper_row, toggle_row, section_card, etc. |
app.R pattern:
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:
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):
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 ...")withposition: 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 fromwidgets.R
Inline interaction (avoiding ID collisions in lapply loops):
# 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:
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 | eingegangenclosed_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
typeis 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 | projectt_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.