Files
2026-07-06 19:25:38 +02:00

12 KiB
Raw Permalink Blame History

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>:latestpodman 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 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 `%
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 ...") 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):

# 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 | 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.