From 58983e6855212b795819d12f19f805238d033506 Mon Sep 17 00:00:00 2001 From: friessn Date: Mon, 6 Jul 2026 19:25:38 +0200 Subject: [PATCH] add homekeeper stack --- .gitignore | 9 + CLAUDE.md | 308 +++++++++++ api/Dockerfile | 7 + api/app/__init__.py | 0 api/app/auth.py | 67 +++ api/app/database.py | 243 +++++++++ api/app/main.py | 45 ++ api/app/models/__init__.py | 0 api/app/models/user.py | 10 + api/app/routers/__init__.py | 0 api/app/routers/actions.py | 94 ++++ api/app/routers/apiaries.py | 99 ++++ api/app/routers/checklists.py | 144 +++++ api/app/routers/colonies.py | 294 +++++++++++ api/app/routers/harvests.py | 136 +++++ api/app/routers/inspections.py | 164 ++++++ api/app/routers/lists.py | 149 ++++++ api/app/routers/queens.py | 141 +++++ api/app/routers/varroa.py | 284 ++++++++++ api/requirements.txt | 7 + beekeeper/.Rbuildignore | 2 + beekeeper/DESCRIPTION | 15 + beekeeper/Dockerfile | 16 + beekeeper/NAMESPACE | 3 + beekeeper/NEWS.md | 15 + beekeeper/R/api_client.R | 88 ++++ beekeeper/R/app.R | 21 + beekeeper/R/db_actions.R | 32 ++ beekeeper/R/db_apiaries.R | 32 ++ beekeeper/R/db_checklists.R | 48 ++ beekeeper/R/db_colonies.R | 52 ++ beekeeper/R/db_harvests.R | 37 ++ beekeeper/R/db_inspections.R | 63 +++ beekeeper/R/db_migrate.R | 1 + beekeeper/R/db_queens.R | 45 ++ beekeeper/R/db_varroa.R | 103 ++++ beekeeper/R/logger.R | 41 ++ beekeeper/R/screen_actions.R | 164 ++++++ beekeeper/R/screen_apiaries.R | 209 ++++++++ beekeeper/R/screen_bulk_action.R | 109 ++++ beekeeper/R/screen_checklists.R | 180 +++++++ beekeeper/R/screen_colonies.R | 145 ++++++ beekeeper/R/screen_colony.R | 490 ++++++++++++++++++ beekeeper/R/screen_harvest.R | 59 +++ beekeeper/R/screen_inspection.R | 255 +++++++++ beekeeper/R/screen_lineage.R | 95 ++++ beekeeper/R/screen_queen.R | 102 ++++ beekeeper/R/screen_quick_insp.R | 113 ++++ beekeeper/R/screen_varroa.R | 181 +++++++ beekeeper/R/server.R | 101 ++++ beekeeper/R/ui.R | 83 +++ beekeeper/R/utils.R | 101 ++++ beekeeper/R/widgets.R | 156 ++++++ beekeeper/app.R | 2 + beekeeper/shiny.Rproj | 17 + docker-compose.yml | 73 +++ infrastructure/README.md | 90 ++++ infrastructure/group_vars/all.yml | 29 ++ infrastructure/inventory/hosts.yml | 7 + infrastructure/roles/common/tasks/main.yml | 45 ++ infrastructure/roles/gitea/tasks/main.yml | 26 + .../roles/gitea/templates/gitea.container.j2 | 23 + .../roles/homekeeper/tasks/main.yml | 43 ++ .../templates/homekeeper-api.container.j2 | 25 + .../homekeeper-beekeeper.container.j2 | 21 + .../templates/homekeeper-db.container.j2 | 23 + .../homekeeper-listkeeper.container.j2 | 22 + .../templates/homekeeper.network.j2 | 2 + infrastructure/roles/nginx/tasks/main.yml | 50 ++ .../roles/nginx/templates/homekeeper.conf.j2 | 77 +++ infrastructure/roles/podman/tasks/main.yml | 59 +++ .../roles/podman/templates/registries.conf.j2 | 3 + infrastructure/site.yml | 10 + listkeeper/DESCRIPTION | 14 + listkeeper/Dockerfile | 15 + listkeeper/NAMESPACE | 3 + listkeeper/NEWS.md | 10 + listkeeper/R/api_client.R | 88 ++++ listkeeper/R/app.R | 21 + listkeeper/R/db_items.R | 15 + listkeeper/R/db_lists.R | 24 + listkeeper/R/db_migrate.R | 1 + listkeeper/R/logger.R | 36 ++ listkeeper/R/screen_home.R | 301 +++++++++++ listkeeper/R/server.R | 3 + listkeeper/R/ui.R | 35 ++ listkeeper/R/utils.R | 1 + nginx.conf | 86 +++ todos.md | 49 ++ www/index.html | 109 ++++ 90 files changed, 6816 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 api/Dockerfile create mode 100644 api/app/__init__.py create mode 100644 api/app/auth.py create mode 100644 api/app/database.py create mode 100644 api/app/main.py create mode 100644 api/app/models/__init__.py create mode 100644 api/app/models/user.py create mode 100644 api/app/routers/__init__.py create mode 100644 api/app/routers/actions.py create mode 100644 api/app/routers/apiaries.py create mode 100644 api/app/routers/checklists.py create mode 100644 api/app/routers/colonies.py create mode 100644 api/app/routers/harvests.py create mode 100644 api/app/routers/inspections.py create mode 100644 api/app/routers/lists.py create mode 100644 api/app/routers/queens.py create mode 100644 api/app/routers/varroa.py create mode 100644 api/requirements.txt create mode 100644 beekeeper/.Rbuildignore create mode 100644 beekeeper/DESCRIPTION create mode 100644 beekeeper/Dockerfile create mode 100644 beekeeper/NAMESPACE create mode 100644 beekeeper/NEWS.md create mode 100644 beekeeper/R/api_client.R create mode 100644 beekeeper/R/app.R create mode 100644 beekeeper/R/db_actions.R create mode 100644 beekeeper/R/db_apiaries.R create mode 100644 beekeeper/R/db_checklists.R create mode 100644 beekeeper/R/db_colonies.R create mode 100644 beekeeper/R/db_harvests.R create mode 100644 beekeeper/R/db_inspections.R create mode 100644 beekeeper/R/db_migrate.R create mode 100644 beekeeper/R/db_queens.R create mode 100644 beekeeper/R/db_varroa.R create mode 100644 beekeeper/R/logger.R create mode 100644 beekeeper/R/screen_actions.R create mode 100644 beekeeper/R/screen_apiaries.R create mode 100644 beekeeper/R/screen_bulk_action.R create mode 100644 beekeeper/R/screen_checklists.R create mode 100644 beekeeper/R/screen_colonies.R create mode 100644 beekeeper/R/screen_colony.R create mode 100644 beekeeper/R/screen_harvest.R create mode 100644 beekeeper/R/screen_inspection.R create mode 100644 beekeeper/R/screen_lineage.R create mode 100644 beekeeper/R/screen_queen.R create mode 100644 beekeeper/R/screen_quick_insp.R create mode 100644 beekeeper/R/screen_varroa.R create mode 100644 beekeeper/R/server.R create mode 100644 beekeeper/R/ui.R create mode 100644 beekeeper/R/utils.R create mode 100644 beekeeper/R/widgets.R create mode 100644 beekeeper/app.R create mode 100644 beekeeper/shiny.Rproj create mode 100644 docker-compose.yml create mode 100644 infrastructure/README.md create mode 100644 infrastructure/group_vars/all.yml create mode 100644 infrastructure/inventory/hosts.yml create mode 100644 infrastructure/roles/common/tasks/main.yml create mode 100644 infrastructure/roles/gitea/tasks/main.yml create mode 100644 infrastructure/roles/gitea/templates/gitea.container.j2 create mode 100644 infrastructure/roles/homekeeper/tasks/main.yml create mode 100644 infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 create mode 100644 infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 create mode 100644 infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 create mode 100644 infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 create mode 100644 infrastructure/roles/homekeeper/templates/homekeeper.network.j2 create mode 100644 infrastructure/roles/nginx/tasks/main.yml create mode 100644 infrastructure/roles/nginx/templates/homekeeper.conf.j2 create mode 100644 infrastructure/roles/podman/tasks/main.yml create mode 100644 infrastructure/roles/podman/templates/registries.conf.j2 create mode 100644 infrastructure/site.yml create mode 100644 listkeeper/DESCRIPTION create mode 100644 listkeeper/Dockerfile create mode 100644 listkeeper/NAMESPACE create mode 100644 listkeeper/NEWS.md create mode 100644 listkeeper/R/api_client.R create mode 100644 listkeeper/R/app.R create mode 100644 listkeeper/R/db_items.R create mode 100644 listkeeper/R/db_lists.R create mode 100644 listkeeper/R/db_migrate.R create mode 100644 listkeeper/R/logger.R create mode 100644 listkeeper/R/screen_home.R create mode 100644 listkeeper/R/server.R create mode 100644 listkeeper/R/ui.R create mode 100644 listkeeper/R/utils.R create mode 100644 nginx.conf create mode 100644 todos.md create mode 100644 www/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..803a470 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +data/ +pg_data/ +*.db +.Rhistory +.RData +.Rproj.user/ +beekeeper/man/ +beekeeper/logs/ +listkeeper/logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..23cdcb3 --- /dev/null +++ b/CLAUDE.md @@ -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_.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. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..137ddde --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/auth.py b/api/app/auth.py new file mode 100644 index 0000000..2ee7613 --- /dev/null +++ b/api/app/auth.py @@ -0,0 +1,67 @@ +import os +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from passlib.context import CryptContext +from sqlalchemy import text + +security = HTTPBasic() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def hash_password(plain: str) -> str: + return pwd_context.hash(plain) + + +def get_current_user(credentials: HTTPBasicCredentials = Depends(security)): + from app.database import engine + + with engine.connect() as conn: + row = conn.execute( + text("SELECT hashed_password FROM auth.users WHERE username = :u"), + {"u": credentials.username}, + ).fetchone() + + if row is None or not verify_password(credentials.password, row[0]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username + + +def seed_users(): + """Seed initial users from INITIAL_USERS env var if users table is empty.""" + from app.database import engine + + initial_users_env = os.getenv("INITIAL_USERS", "") + if not initial_users_env: + return + + with engine.connect() as conn: + count = conn.execute(text("SELECT COUNT(*) FROM auth.users")).scalar() + if count and count > 0: + return + + for pair in initial_users_env.split(","): + pair = pair.strip() + if ":" not in pair: + continue + username, password = pair.split(":", 1) + username = username.strip() + password = password.strip() + if not username or not password: + continue + hashed = hash_password(password) + conn.execute( + text( + "INSERT INTO auth.users (username, hashed_password) " + "VALUES (:u, :h) ON CONFLICT (username) DO NOTHING" + ), + {"u": username, "h": hashed}, + ) + conn.commit() diff --git a/api/app/database.py b/api/app/database.py new file mode 100644 index 0000000..f328568 --- /dev/null +++ b/api/app/database.py @@ -0,0 +1,243 @@ +import os +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + +_DB_HOST = os.getenv("DB_HOST", "localhost") +_DB_PORT = os.getenv("DB_PORT", "5432") +_DB_NAME = os.getenv("DB_NAME", "homestead") +_DB_USER = os.getenv("DB_USER", "homestead") +_DB_PASSWORD = os.getenv("DB_PASSWORD", "homestead") + +DATABASE_URL = ( + f"postgresql+psycopg2://{_DB_USER}:{_DB_PASSWORD}@{_DB_HOST}:{_DB_PORT}/{_DB_NAME}" +) + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) + + +def get_session(): + with Session(engine) as session: + yield session + + +def init_db(): + """Create schemas and tables, then seed initial users.""" + from app.auth import seed_users + + with engine.connect() as conn: + # Create auth schema and users table + conn.execute(text("CREATE SCHEMA IF NOT EXISTS auth")) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS auth.users ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + hashed_password TEXT NOT NULL + ) + """)) + + # Create lk schema and tables + conn.execute(text("CREATE SCHEMA IF NOT EXISTS lk")) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS lk.lists ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('shopping', 'todo', 'project')), + t_shirt_size TEXT CHECK (t_shirt_size IN ('XS', 'S', 'M', 'L', 'XL')), + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + closed_at TIMESTAMPTZ + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS lk.list_items ( + id SERIAL PRIMARY KEY, + list_id INTEGER NOT NULL REFERENCES lk.lists(id) ON DELETE CASCADE, + text TEXT NOT NULL, + checked BOOLEAN NOT NULL DEFAULT FALSE, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() + ) + """)) + + # Create public schema beekeeper tables + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS apiaries ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + location_text TEXT, + lat REAL, + lng REAL, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS colonies ( + id SERIAL PRIMARY KEY, + apiary_id INTEGER REFERENCES apiaries(id), + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'wirtschaftsvolk', + parent_colony_id INTEGER REFERENCES colonies(id), + current_queen_id INTEGER, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMPTZ + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS colony_locations ( + id SERIAL PRIMARY KEY, + colony_id INTEGER NOT NULL REFERENCES colonies(id), + apiary_id INTEGER NOT NULL REFERENCES apiaries(id), + moved_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS queens ( + id SERIAL PRIMARY KEY, + colony_id INTEGER REFERENCES colonies(id), + year INTEGER, + breed TEXT, + origin_colony_id INTEGER REFERENCES colonies(id), + marked_color TEXT, + notes TEXT, + introduced_at TEXT, + superseded_at TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS inspections ( + id SERIAL PRIMARY KEY, + colony_id INTEGER NOT NULL REFERENCES colonies(id), + date TEXT NOT NULL, + inspector TEXT, + brood_frames INTEGER, + honey_frames INTEGER, + food_stores_kg REAL, + colony_strength INTEGER, + queen_seen INTEGER NOT NULL DEFAULT 0, + eggs_seen INTEGER NOT NULL DEFAULT 0, + queen_cells_count INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS inspection_traits ( + id SERIAL PRIMARY KEY, + inspection_id INTEGER NOT NULL REFERENCES inspections(id) ON DELETE CASCADE, + trait TEXT NOT NULL, + score INTEGER NOT NULL CHECK (score BETWEEN 1 AND 4) + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS varroa_counts ( + id SERIAL PRIMARY KEY, + colony_id INTEGER NOT NULL REFERENCES colonies(id), + date_from TEXT, + date TEXT NOT NULL, + time TEXT, + method TEXT NOT NULL, + count REAL NOT NULL, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS varroa_treatments ( + id SERIAL PRIMARY KEY, + colony_id INTEGER NOT NULL REFERENCES colonies(id), + date TEXT NOT NULL, + time TEXT, + product TEXT NOT NULL, + method TEXT, + dosage TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS varroa_controls ( + id SERIAL PRIMARY KEY, + treatment_id INTEGER NOT NULL REFERENCES varroa_treatments(id) ON DELETE CASCADE, + colony_id INTEGER NOT NULL REFERENCES colonies(id), + date TEXT NOT NULL, + time TEXT, + result TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS colony_actions ( + id SERIAL PRIMARY KEY, + colony_id INTEGER NOT NULL REFERENCES colonies(id), + date TEXT NOT NULL, + time TEXT, + type TEXT NOT NULL, + notes TEXT, + related_colony_id INTEGER REFERENCES colonies(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS honey_harvests ( + id SERIAL PRIMARY KEY, + colony_id INTEGER REFERENCES colonies(id), + apiary_id INTEGER REFERENCES apiaries(id), + date TEXT NOT NULL, + time TEXT, + weight_kg REAL NOT NULL, + type TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS checklists ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'colony', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS checklist_items ( + id SERIAL PRIMARY KEY, + checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, + text TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0 + ) + """)) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS checklist_completions ( + id SERIAL PRIMARY KEY, + checklist_id INTEGER NOT NULL REFERENCES checklists(id), + item_id INTEGER NOT NULL REFERENCES checklist_items(id), + scope_id INTEGER NOT NULL, + date TEXT NOT NULL, + completed_by TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + + # Add columns that may be missing from older installs (idempotent) + for stmt in [ + "ALTER TABLE varroa_counts ADD COLUMN IF NOT EXISTS date_from TEXT", + "ALTER TABLE varroa_counts ADD COLUMN IF NOT EXISTS time TEXT", + "ALTER TABLE varroa_treatments ADD COLUMN IF NOT EXISTS time TEXT", + "ALTER TABLE varroa_controls ADD COLUMN IF NOT EXISTS time TEXT", + "ALTER TABLE honey_harvests ADD COLUMN IF NOT EXISTS time TEXT", + "ALTER TABLE colony_actions ADD COLUMN IF NOT EXISTS related_colony_id INTEGER REFERENCES colonies(id)", + ]: + try: + conn.execute(text(stmt)) + except Exception: + pass + + conn.commit() + + seed_users() diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..02f4141 --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,45 @@ +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI + +from app.routers import ( + apiaries, + colonies, + inspections, + varroa, + harvests, + actions, + queens, + checklists, + lists, +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + from app.database import init_db + init_db() + yield + + +app = FastAPI( + title="Beekeeper API", + version="1.0.0", + root_path=os.getenv("ROOT_PATH", ""), + lifespan=lifespan, +) + +app.include_router(apiaries.router, prefix="/v1/apiaries", tags=["apiaries"]) +app.include_router(colonies.router, prefix="/v1/colonies", tags=["colonies"]) +app.include_router(inspections.router, prefix="/v1/inspections", tags=["inspections"]) +app.include_router(varroa.router, prefix="/v1/varroa", tags=["varroa"]) +app.include_router(harvests.router, prefix="/v1/harvests", tags=["harvests"]) +app.include_router(actions.router, prefix="/v1/actions", tags=["actions"]) +app.include_router(queens.router, prefix="/v1/queens", tags=["queens"]) +app.include_router(checklists.router, prefix="/v1/checklists", tags=["checklists"]) +app.include_router(lists.router, prefix="/v1/lists", tags=["lists"]) + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/models/user.py b/api/app/models/user.py new file mode 100644 index 0000000..1a85051 --- /dev/null +++ b/api/app/models/user.py @@ -0,0 +1,10 @@ +from sqlmodel import Field, SQLModel + + +class User(SQLModel, table=True): + __tablename__ = "users" + __table_args__ = {"schema": "auth"} + + id: int | None = Field(default=None, primary_key=True) + username: str = Field(unique=True, index=True) + hashed_password: str diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routers/actions.py b/api/app/routers/actions.py new file mode 100644 index 0000000..4fd6831 --- /dev/null +++ b/api/app/routers/actions.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("") +def list_actions( + colony_id: int = Query(...), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT * FROM colony_actions WHERE colony_id = :colony_id + ORDER BY date::DATE DESC, created_at DESC + """), {"colony_id": colony_id}).fetchall() + return [_row(r) for r in rows] + + +@router.post("", status_code=201) +def create_action( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO colony_actions (colony_id, date, time, type, notes, related_colony_id) + VALUES (:colony_id, :date, :time, :type, :notes, :related_colony_id) + RETURNING * + """), { + "colony_id": body.get("colony_id"), + "date": body.get("date"), + "time": body.get("time"), + "type": body.get("type"), + "notes": body.get("notes"), + "related_colony_id": body.get("related_colony_id"), + }).fetchone() + session.commit() + return _row(row) + + +@router.get("/{id}") +def get_action( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM colony_actions WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.put("/{id}") +def update_action( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE colony_actions SET date = :date, type = :type, notes = :notes + WHERE id = :id RETURNING * + """), { + "id": id, + "date": body.get("date"), + "type": body.get("type"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/{id}", status_code=204) +def delete_action( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM colony_actions WHERE id = :id"), {"id": id}) + session.commit() diff --git a/api/app/routers/apiaries.py b/api/app/routers/apiaries.py new file mode 100644 index 0000000..5034d33 --- /dev/null +++ b/api/app/routers/apiaries.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("") +def list_apiaries( + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT a.*, COUNT(c.id) AS colony_count + FROM apiaries a + LEFT JOIN colonies c ON c.apiary_id = a.id AND c.closed_at IS NULL + GROUP BY a.id + ORDER BY a.name + """)).fetchall() + return [_row(r) for r in rows] + + +@router.get("/{id}") +def get_apiary( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM apiaries WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.post("", status_code=201) +def create_apiary( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO apiaries (name, location_text, lat, lng, notes) + VALUES (:name, :location_text, :lat, :lng, :notes) + RETURNING * + """), { + "name": body.get("name"), + "location_text": body.get("location_text"), + "lat": body.get("lat"), + "lng": body.get("lng"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + return _row(row) + + +@router.put("/{id}") +def update_apiary( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE apiaries + SET name = :name, location_text = :location_text, lat = :lat, lng = :lng, notes = :notes + WHERE id = :id + RETURNING * + """), { + "id": id, + "name": body.get("name"), + "location_text": body.get("location_text"), + "lat": body.get("lat"), + "lng": body.get("lng"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/{id}", status_code=204) +def delete_apiary( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM apiaries WHERE id = :id"), {"id": id}) + session.commit() diff --git a/api/app/routers/checklists.py b/api/app/routers/checklists.py new file mode 100644 index 0000000..1e810d6 --- /dev/null +++ b/api/app/routers/checklists.py @@ -0,0 +1,144 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("") +def list_checklists( + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text("SELECT * FROM checklists ORDER BY name")).fetchall() + return [_row(r) for r in rows] + + +@router.post("", status_code=201) +def create_checklist( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO checklists (name, scope) VALUES (:name, :scope) RETURNING * + """), {"name": body.get("name"), "scope": body.get("scope", "colony")}).fetchone() + session.commit() + return _row(row) + + +@router.delete("/{id}", status_code=204) +def delete_checklist( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM checklists WHERE id = :id"), {"id": id}) + session.commit() + + +@router.get("/{id}/items") +def list_items( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT * FROM checklist_items WHERE checklist_id = :id ORDER BY position + """), {"id": id}).fetchall() + return [_row(r) for r in rows] + + +@router.post("/{id}/items", status_code=201) +def create_item( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO checklist_items (checklist_id, text, position) + VALUES (:checklist_id, :text, :position) RETURNING * + """), { + "checklist_id": id, + "text": body.get("text"), + "position": body.get("position", 0), + }).fetchone() + session.commit() + return _row(row) + + +@router.delete("/items/{item_id}", status_code=204) +def delete_item( + item_id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM checklist_items WHERE id = :id"), {"id": item_id}) + session.commit() + + +@router.get("/{id}/completions") +def list_completions( + id: int, + scope_id: int = Query(...), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT cc.*, ci.text, ci.position + FROM checklist_completions cc + JOIN checklist_items ci ON ci.id = cc.item_id + WHERE cc.checklist_id = :checklist_id AND cc.scope_id = :scope_id + ORDER BY ci.position + """), {"checklist_id": id, "scope_id": scope_id}).fetchall() + return [_row(r) for r in rows] + + +@router.post("/{id}/completions/toggle") +def toggle_completion( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + item_id = body.get("item_id") + scope_id = body.get("scope_id") + date = body.get("date") + completed_by = body.get("completed_by") + notes = body.get("notes") + + existing = session.execute(text(""" + SELECT id FROM checklist_completions + WHERE checklist_id = :checklist_id AND item_id = :item_id AND scope_id = :scope_id + """), {"checklist_id": id, "item_id": item_id, "scope_id": scope_id}).fetchone() + + if existing is not None: + session.execute( + text("DELETE FROM checklist_completions WHERE id = :id"), + {"id": existing._mapping["id"]}, + ) + session.commit() + return {"toggled": False} + else: + session.execute(text(""" + INSERT INTO checklist_completions (checklist_id, item_id, scope_id, date, completed_by, notes) + VALUES (:checklist_id, :item_id, :scope_id, :date, :completed_by, :notes) + """), { + "checklist_id": id, + "item_id": item_id, + "scope_id": scope_id, + "date": date, + "completed_by": completed_by, + "notes": notes, + }) + session.commit() + return {"toggled": True} diff --git a/api/app/routers/colonies.py b/api/app/routers/colonies.py new file mode 100644 index 0000000..664aa61 --- /dev/null +++ b/api/app/routers/colonies.py @@ -0,0 +1,294 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("") +def list_colonies( + apiary_id: int | None = Query(default=None), + include_closed: bool = Query(default=False), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + where = "1=1" if include_closed else "c.closed_at IS NULL" + params: dict[str, Any] = {} + if apiary_id is not None: + where += " AND c.apiary_id = :apiary_id" + params["apiary_id"] = apiary_id + + rows = session.execute(text(f""" + SELECT + c.*, + a.name AS apiary_name, + p.name AS parent_name, + q.year AS queen_year, + q.breed AS queen_breed, + (SELECT MAX(i.date::DATE) + FROM inspections i + WHERE i.colony_id = c.id) AS last_inspection_date + FROM colonies c + LEFT JOIN apiaries a ON a.id = c.apiary_id + LEFT JOIN colonies p ON p.id = c.parent_colony_id + LEFT JOIN queens q ON q.id = c.current_queen_id + WHERE {where} + ORDER BY a.name, c.name + """), params).fetchall() + return [_row(r) for r in rows] + + +@router.get("/log-placeholder", include_in_schema=False) +def _log_placeholder(): + pass # just to force ordering; actual log route is below + + +@router.get("/{id}/log") +def colony_log( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT date, type, summary, ref_id, created_at FROM ( + SELECT date, 'durchsicht' AS type, + TRIM(BOTH FROM CONCAT_WS(' ', + CASE WHEN brood_frames IS NOT NULL THEN brood_frames::TEXT || ' BW' END, + CASE WHEN honey_frames IS NOT NULL THEN honey_frames::TEXT || ' HW' END, + CASE WHEN colony_strength IS NOT NULL THEN 'Stärke ' || colony_strength::TEXT END, + CASE WHEN queen_seen = 1 THEN '♛ gesehen' END + )) AS summary, + id AS ref_id, created_at + FROM inspections WHERE colony_id = :colony_id + + UNION ALL + + SELECT date, 'kontrolle' AS type, + count::TEXT || ' Milben' || + CASE WHEN date_from IS NOT NULL + THEN ' · ' || (date::DATE - date_from::DATE)::TEXT || ' Tage' ELSE '' END AS summary, + id AS ref_id, created_at + FROM varroa_counts WHERE colony_id = :colony_id + + UNION ALL + + SELECT date, 'behandlung' AS type, + product || CASE WHEN dosage IS NOT NULL AND dosage <> '' + THEN ' · ' || dosage ELSE '' END AS summary, + id AS ref_id, created_at + FROM varroa_treatments WHERE colony_id = :colony_id + + UNION ALL + + SELECT vc.date, 'nachkontrolle' AS type, + vt.product || CASE WHEN vc.result IS NOT NULL AND vc.result <> '' + THEN ': ' || vc.result ELSE '' END AS summary, + vc.id AS ref_id, vc.created_at + FROM varroa_controls vc + JOIN varroa_treatments vt ON vt.id = vc.treatment_id + WHERE vc.colony_id = :colony_id + + UNION ALL + + SELECT h.date, 'ernte' AS type, + h.weight_kg::TEXT || ' kg' || + CASE WHEN h.type IS NOT NULL THEN ' · ' || h.type ELSE '' END AS summary, + h.id AS ref_id, h.created_at + FROM honey_harvests h WHERE h.colony_id = :colony_id + + UNION ALL + + SELECT ca.date, 'massnahme' AS type, + CASE ca.type + WHEN 'ablegerbildung' THEN 'Ablegerbildung' + WHEN 'zwischenbodenableger' THEN 'Zwischenbödenableger' + WHEN 'brutentnahme' THEN 'Brutentnahme' + WHEN 'standwechsel' THEN 'Standwechsel' + WHEN 'futterung' THEN 'Fütterung' + WHEN 'vereinigung' THEN 'Völkervereinigung' + ELSE 'Maßnahme' + END || + CASE WHEN ca.notes IS NOT NULL AND ca.notes <> '' + THEN ': ' || ca.notes ELSE '' END AS summary, + ca.id AS ref_id, ca.created_at + FROM colony_actions ca WHERE ca.colony_id = :colony_id + ) log + ORDER BY date::DATE DESC, created_at DESC + """), {"colony_id": id}).fetchall() + return [_row(r) for r in rows] + + +@router.get("/{id}/lineage") +def colony_lineage( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + WITH RECURSIVE lineage(id, name, status, parent_colony_id, depth) AS ( + SELECT id, name, status, parent_colony_id, 0 + FROM colonies WHERE id = :colony_id + UNION ALL + SELECT c.id, c.name, c.status, c.parent_colony_id, l.depth + 1 + FROM colonies c + JOIN lineage l ON c.id = l.parent_colony_id + ) + SELECT * FROM lineage ORDER BY depth DESC + """), {"colony_id": id}).fetchall() + return [_row(r) for r in rows] + + +@router.get("/{id}/descendants") +def colony_descendants( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + WITH RECURSIVE descendants(id, name, status, parent_colony_id, depth) AS ( + SELECT id, name, status, parent_colony_id, 0 + FROM colonies WHERE id = :colony_id + UNION ALL + SELECT c.id, c.name, c.status, c.parent_colony_id, d.depth + 1 + FROM colonies c + JOIN descendants d ON c.parent_colony_id = d.id + ) + SELECT * FROM descendants WHERE depth > 0 ORDER BY depth, name + """), {"colony_id": id}).fetchall() + return [_row(r) for r in rows] + + +@router.get("/{id}/location-history") +def colony_location_history( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT cl.*, a.name AS apiary_name + FROM colony_locations cl + JOIN apiaries a ON a.id = cl.apiary_id + WHERE cl.colony_id = :colony_id + ORDER BY cl.moved_at DESC + """), {"colony_id": id}).fetchall() + return [_row(r) for r in rows] + + +@router.get("/{id}") +def get_colony( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + SELECT c.*, a.name AS apiary_name, p.name AS parent_name + FROM colonies c + LEFT JOIN apiaries a ON a.id = c.apiary_id + LEFT JOIN colonies p ON p.id = c.parent_colony_id + WHERE c.id = :id + """), {"id": id}).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.post("", status_code=201) +def create_colony( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO colonies (name, apiary_id, status, parent_colony_id, notes) + VALUES (:name, :apiary_id, :status, :parent_colony_id, :notes) + RETURNING * + """), { + "name": body.get("name"), + "apiary_id": body.get("apiary_id"), + "status": body.get("status", "wirtschaftsvolk"), + "parent_colony_id": body.get("parent_colony_id"), + "notes": body.get("notes"), + }).fetchone() + colony_id = row._mapping["id"] + session.execute(text(""" + INSERT INTO colony_locations (colony_id, apiary_id) VALUES (:colony_id, :apiary_id) + """), {"colony_id": colony_id, "apiary_id": body.get("apiary_id")}) + session.commit() + return _row(row) + + +@router.put("/{id}") +def update_colony( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + # Check if apiary changed (to insert colony_location) + old = session.execute( + text("SELECT apiary_id FROM colonies WHERE id = :id"), {"id": id} + ).fetchone() + + result = session.execute(text(""" + UPDATE colonies + SET name = :name, apiary_id = :apiary_id, status = :status, notes = :notes + WHERE id = :id + RETURNING * + """), { + "id": id, + "name": body.get("name"), + "apiary_id": body.get("apiary_id"), + "status": body.get("status"), + "notes": body.get("notes"), + }).fetchone() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + + new_apiary_id = body.get("apiary_id") + if old is not None and old._mapping["apiary_id"] != new_apiary_id: + session.execute(text(""" + INSERT INTO colony_locations (colony_id, apiary_id) VALUES (:colony_id, :apiary_id) + """), {"colony_id": id, "apiary_id": new_apiary_id}) + + session.commit() + return _row(result) + + +@router.post("/{id}/close") +def close_colony( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE colonies SET status = 'eingegangen', closed_at = NOW() + WHERE id = :id RETURNING * + """), {"id": id}).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.post("/{id}/set-queen") +def set_queen( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE colonies SET current_queen_id = :queen_id WHERE id = :id RETURNING * + """), {"id": id, "queen_id": body.get("queen_id")}).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) diff --git a/api/app/routers/harvests.py b/api/app/routers/harvests.py new file mode 100644 index 0000000..3f3415a --- /dev/null +++ b/api/app/routers/harvests.py @@ -0,0 +1,136 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("/summary") +def harvest_summary( + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT + EXTRACT(YEAR FROM date::date)::INTEGER AS year, + type, + SUM(weight_kg) AS total_kg, + COUNT(*) AS n_harvests + FROM honey_harvests + GROUP BY year, type + ORDER BY year DESC, total_kg DESC + """)).fetchall() + return [_row(r) for r in rows] + + +@router.get("") +def list_harvests( + colony_id: int | None = Query(default=None), + apiary_id: int | None = Query(default=None), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + if colony_id is not None: + rows = session.execute(text(""" + SELECT h.*, c.name AS colony_name, a.name AS apiary_name + FROM honey_harvests h + LEFT JOIN colonies c ON c.id = h.colony_id + LEFT JOIN apiaries a ON a.id = h.apiary_id + WHERE h.colony_id = :colony_id + ORDER BY h.date DESC + """), {"colony_id": colony_id}).fetchall() + elif apiary_id is not None: + rows = session.execute(text(""" + SELECT h.*, c.name AS colony_name, a.name AS apiary_name + FROM honey_harvests h + LEFT JOIN colonies c ON c.id = h.colony_id + LEFT JOIN apiaries a ON a.id = h.apiary_id + WHERE h.apiary_id = :apiary_id + ORDER BY h.date DESC + """), {"apiary_id": apiary_id}).fetchall() + else: + rows = session.execute(text(""" + SELECT h.*, c.name AS colony_name, a.name AS apiary_name + FROM honey_harvests h + LEFT JOIN colonies c ON c.id = h.colony_id + LEFT JOIN apiaries a ON a.id = h.apiary_id + ORDER BY h.date DESC + """)).fetchall() + return [_row(r) for r in rows] + + +@router.post("", status_code=201) +def create_harvest( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO honey_harvests (date, weight_kg, colony_id, apiary_id, type, notes, time) + VALUES (:date, :weight_kg, :colony_id, :apiary_id, :type, :notes, :time) + RETURNING * + """), { + "date": body.get("date"), + "weight_kg": body.get("weight_kg"), + "colony_id": body.get("colony_id"), + "apiary_id": body.get("apiary_id"), + "type": body.get("type"), + "notes": body.get("notes"), + "time": body.get("time"), + }).fetchone() + session.commit() + return _row(row) + + +@router.get("/{id}") +def get_harvest( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM honey_harvests WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.put("/{id}") +def update_harvest( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE honey_harvests SET date = :date, weight_kg = :weight_kg, notes = :notes + WHERE id = :id RETURNING * + """), { + "id": id, + "date": body.get("date"), + "weight_kg": body.get("weight_kg"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/{id}", status_code=204) +def delete_harvest( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM honey_harvests WHERE id = :id"), {"id": id}) + session.commit() diff --git a/api/app/routers/inspections.py b/api/app/routers/inspections.py new file mode 100644 index 0000000..f956110 --- /dev/null +++ b/api/app/routers/inspections.py @@ -0,0 +1,164 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("") +def list_inspections( + colony_id: int = Query(...), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT * FROM inspections WHERE colony_id = :colony_id + ORDER BY date::DATE DESC, id DESC + """), {"colony_id": colony_id}).fetchall() + return [_row(r) for r in rows] + + +@router.get("/last") +def last_inspection( + colony_id: int = Query(...), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + row = session.execute(text(""" + SELECT * FROM inspections WHERE colony_id = :colony_id + ORDER BY date::DATE DESC, id DESC LIMIT 1 + """), {"colony_id": colony_id}).fetchone() + if row is None: + return None + return _row(row) + + +@router.get("/{id}/traits") +def inspection_traits( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT * FROM inspection_traits WHERE inspection_id = :id + """), {"id": id}).fetchall() + return [_row(r) for r in rows] + + +@router.get("/{id}") +def get_inspection( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM inspections WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.post("", status_code=201) +def create_inspection( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO inspections + (colony_id, date, inspector, brood_frames, honey_frames, food_stores_kg, + colony_strength, queen_seen, eggs_seen, queen_cells_count, notes) + VALUES + (:colony_id, :date, :inspector, :brood_frames, :honey_frames, :food_stores_kg, + :colony_strength, :queen_seen, :eggs_seen, :queen_cells_count, :notes) + RETURNING * + """), { + "colony_id": body.get("colony_id"), + "date": body.get("date"), + "inspector": body.get("inspector"), + "brood_frames": body.get("brood_frames"), + "honey_frames": body.get("honey_frames"), + "food_stores_kg": body.get("food_stores_kg"), + "colony_strength": body.get("colony_strength"), + "queen_seen": int(body.get("queen_seen", 0)), + "eggs_seen": int(body.get("eggs_seen", 0)), + "queen_cells_count": int(body.get("queen_cells_count", 0)), + "notes": body.get("notes"), + }).fetchone() + inspection_id = row._mapping["id"] + + traits = body.get("traits", {}) + for trait, score in (traits or {}).items(): + if score is not None: + session.execute(text(""" + INSERT INTO inspection_traits (inspection_id, trait, score) + VALUES (:inspection_id, :trait, :score) + """), {"inspection_id": inspection_id, "trait": trait, "score": int(score)}) + + session.commit() + return _row(row) + + +@router.put("/{id}") +def update_inspection( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE inspections SET + date = :date, inspector = :inspector, brood_frames = :brood_frames, + honey_frames = :honey_frames, food_stores_kg = :food_stores_kg, + colony_strength = :colony_strength, queen_seen = :queen_seen, + eggs_seen = :eggs_seen, queen_cells_count = :queen_cells_count, notes = :notes + WHERE id = :id + RETURNING * + """), { + "id": id, + "date": body.get("date"), + "inspector": body.get("inspector"), + "brood_frames": body.get("brood_frames"), + "honey_frames": body.get("honey_frames"), + "food_stores_kg": body.get("food_stores_kg"), + "colony_strength": body.get("colony_strength"), + "queen_seen": int(body.get("queen_seen", 0)), + "eggs_seen": int(body.get("eggs_seen", 0)), + "queen_cells_count": int(body.get("queen_cells_count", 0)), + "notes": body.get("notes"), + }).fetchone() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + + session.execute( + text("DELETE FROM inspection_traits WHERE inspection_id = :id"), {"id": id} + ) + traits = body.get("traits", {}) + for trait, score in (traits or {}).items(): + if score is not None: + session.execute(text(""" + INSERT INTO inspection_traits (inspection_id, trait, score) + VALUES (:inspection_id, :trait, :score) + """), {"inspection_id": id, "trait": trait, "score": int(score)}) + + session.commit() + return _row(result) + + +@router.delete("/{id}", status_code=204) +def delete_inspection( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM inspections WHERE id = :id"), {"id": id}) + session.commit() diff --git a/api/app/routers/lists.py b/api/app/routers/lists.py new file mode 100644 index 0000000..e4617d3 --- /dev/null +++ b/api/app/routers/lists.py @@ -0,0 +1,149 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("") +def list_lists( + include_closed: bool = Query(default=False), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + where = "" if include_closed else "WHERE l.closed_at IS NULL" + order = "ORDER BY l.closed_at IS NOT NULL, l.created_at DESC" if include_closed else "ORDER BY l.created_at DESC" + rows = session.execute(text(f""" + SELECT + l.*, + COUNT(i.id) AS item_count, + COUNT(i.id) FILTER (WHERE i.checked) AS checked_count + FROM lk.lists l + LEFT JOIN lk.list_items i ON i.list_id = l.id + {where} + GROUP BY l.id + {order} + """)).fetchall() + return [_row(r) for r in rows] + + +@router.post("", status_code=201) +def create_list( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO lk.lists (name, type, t_shirt_size, notes) + VALUES (:name, :type, :t_shirt_size, :notes) + RETURNING * + """), { + "name": body.get("name"), + "type": body.get("type"), + "t_shirt_size": body.get("t_shirt_size"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + return _row(row) + + +@router.post("/{id}/close") +def close_list( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE lk.lists SET status = 'closed', closed_at = now() + WHERE id = :id RETURNING * + """), {"id": id}).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.post("/{id}/reopen") +def reopen_list( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE lk.lists SET status = 'open', closed_at = NULL + WHERE id = :id RETURNING * + """), {"id": id}).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/{id}", status_code=204) +def delete_list( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM lk.lists WHERE id = :id"), {"id": id}) + session.commit() + + +@router.get("/{id}/items") +def list_items( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT * FROM lk.list_items WHERE list_id = :list_id ORDER BY position, created_at + """), {"list_id": id}).fetchall() + return [_row(r) for r in rows] + + +@router.post("/{id}/items", status_code=201) +def create_item( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO lk.list_items (list_id, text) VALUES (:list_id, :text) RETURNING * + """), {"list_id": id, "text": body.get("text")}).fetchone() + session.commit() + return _row(row) + + +@router.patch("/items/{id}") +def update_item_checked( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE lk.list_items SET checked = :checked WHERE id = :id RETURNING * + """), {"id": id, "checked": bool(body.get("checked", False))}).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/items/{id}", status_code=204) +def delete_item( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM lk.list_items WHERE id = :id"), {"id": id}) + session.commit() diff --git a/api/app/routers/queens.py b/api/app/routers/queens.py new file mode 100644 index 0000000..03921ef --- /dev/null +++ b/api/app/routers/queens.py @@ -0,0 +1,141 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +@router.get("") +def list_queens( + colony_id: int | None = Query(default=None), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + params: dict[str, Any] = {} + where = "" + if colony_id is not None: + where = "WHERE q.colony_id = :colony_id" + params["colony_id"] = colony_id + + rows = session.execute(text(f""" + SELECT q.*, c.name AS colony_name, o.name AS origin_colony_name + FROM queens q + LEFT JOIN colonies c ON c.id = q.colony_id + LEFT JOIN colonies o ON o.id = q.origin_colony_id + {where} + ORDER BY q.introduced_at DESC + """), params).fetchall() + return [_row(r) for r in rows] + + +@router.post("", status_code=201) +def create_queen( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO queens (colony_id, year, breed, origin_colony_id, marked_color, notes, introduced_at) + VALUES (:colony_id, :year, :breed, :origin_colony_id, :marked_color, :notes, :introduced_at) + RETURNING * + """), { + "colony_id": body.get("colony_id"), + "year": body.get("year"), + "breed": body.get("breed"), + "origin_colony_id": body.get("origin_colony_id"), + "marked_color": body.get("marked_color"), + "notes": body.get("notes"), + "introduced_at": body.get("introduced_at"), + }).fetchone() + queen_id = row._mapping["id"] + colony_id = body.get("colony_id") + if colony_id is not None: + session.execute(text(""" + UPDATE colonies SET current_queen_id = :queen_id WHERE id = :colony_id + """), {"queen_id": queen_id, "colony_id": colony_id}) + session.commit() + return _row(row) + + +@router.get("/{id}") +def get_queen( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM queens WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.put("/{id}") +def update_queen( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE queens SET + colony_id = :colony_id, year = :year, breed = :breed, + origin_colony_id = :origin_colony_id, marked_color = :marked_color, + notes = :notes, introduced_at = :introduced_at, superseded_at = :superseded_at + WHERE id = :id + RETURNING * + """), { + "id": id, + "colony_id": body.get("colony_id"), + "year": body.get("year"), + "breed": body.get("breed"), + "origin_colony_id": body.get("origin_colony_id"), + "marked_color": body.get("marked_color"), + "notes": body.get("notes"), + "introduced_at": body.get("introduced_at"), + "superseded_at": body.get("superseded_at"), + }).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.post("/{id}/supersede") +def supersede_queen( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE queens SET superseded_at = CURRENT_DATE WHERE id = :id RETURNING * + """), {"id": id}).fetchone() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + colony_id = result._mapping.get("colony_id") + if colony_id is not None: + session.execute(text(""" + UPDATE colonies SET current_queen_id = NULL + WHERE id = :colony_id AND current_queen_id = :queen_id + """), {"colony_id": colony_id, "queen_id": id}) + session.commit() + return _row(result) + + +@router.delete("/{id}", status_code=204) +def delete_queen( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM queens WHERE id = :id"), {"id": id}) + session.commit() diff --git a/api/app/routers/varroa.py b/api/app/routers/varroa.py new file mode 100644 index 0000000..e322f16 --- /dev/null +++ b/api/app/routers/varroa.py @@ -0,0 +1,284 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Any + +from app.database import get_session +from app.auth import get_current_user + +router = APIRouter() + + +def _row(r) -> dict: + return dict(r._mapping) + + +# ---- Counts ---- + +@router.get("/counts") +def list_counts( + colony_id: int = Query(...), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT * FROM varroa_counts WHERE colony_id = :colony_id ORDER BY date DESC + """), {"colony_id": colony_id}).fetchall() + return [_row(r) for r in rows] + + +@router.post("/counts", status_code=201) +def create_count( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO varroa_counts (colony_id, date_from, date, method, count, notes, time) + VALUES (:colony_id, :date_from, :date, :method, :count, :notes, :time) + RETURNING * + """), { + "colony_id": body.get("colony_id"), + "date_from": body.get("date_from"), + "date": body.get("date"), + "method": body.get("method"), + "count": body.get("count"), + "notes": body.get("notes"), + "time": body.get("time"), + }).fetchone() + session.commit() + return _row(row) + + +@router.get("/counts/{id}") +def get_count( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM varroa_counts WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.put("/counts/{id}") +def update_count( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE varroa_counts + SET date_from = :date_from, date = :date, method = :method, count = :count, notes = :notes + WHERE id = :id + RETURNING * + """), { + "id": id, + "date_from": body.get("date_from"), + "date": body.get("date"), + "method": body.get("method"), + "count": body.get("count"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/counts/{id}", status_code=204) +def delete_count( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM varroa_counts WHERE id = :id"), {"id": id}) + session.commit() + + +# ---- Treatments ---- + +@router.get("/treatments") +def list_treatments( + colony_id: int = Query(...), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + rows = session.execute(text(""" + SELECT * FROM varroa_treatments WHERE colony_id = :colony_id ORDER BY date DESC + """), {"colony_id": colony_id}).fetchall() + return [_row(r) for r in rows] + + +@router.post("/treatments", status_code=201) +def create_treatment( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO varroa_treatments (colony_id, date, product, method, dosage, notes, time) + VALUES (:colony_id, :date, :product, :method, :dosage, :notes, :time) + RETURNING * + """), { + "colony_id": body.get("colony_id"), + "date": body.get("date"), + "product": body.get("product"), + "method": body.get("method"), + "dosage": body.get("dosage"), + "notes": body.get("notes"), + "time": body.get("time"), + }).fetchone() + session.commit() + return _row(row) + + +@router.get("/treatments/{id}") +def get_treatment( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM varroa_treatments WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.put("/treatments/{id}") +def update_treatment( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE varroa_treatments + SET date = :date, product = :product, method = :method, dosage = :dosage, notes = :notes + WHERE id = :id + RETURNING * + """), { + "id": id, + "date": body.get("date"), + "product": body.get("product"), + "method": body.get("method"), + "dosage": body.get("dosage"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/treatments/{id}", status_code=204) +def delete_treatment( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM varroa_treatments WHERE id = :id"), {"id": id}) + session.commit() + + +# ---- Controls ---- + +@router.get("/controls") +def list_controls( + colony_id: int | None = Query(default=None), + treatment_id: int | None = Query(default=None), + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> list[dict]: + if treatment_id is not None: + rows = session.execute(text(""" + SELECT vc.*, vt.product, vt.date AS treatment_date + FROM varroa_controls vc + JOIN varroa_treatments vt ON vt.id = vc.treatment_id + WHERE vc.treatment_id = :treatment_id + ORDER BY vc.date DESC + """), {"treatment_id": treatment_id}).fetchall() + elif colony_id is not None: + rows = session.execute(text(""" + SELECT vc.*, vt.product, vt.date AS treatment_date + FROM varroa_controls vc + JOIN varroa_treatments vt ON vt.id = vc.treatment_id + WHERE vc.colony_id = :colony_id + ORDER BY vc.date DESC + """), {"colony_id": colony_id}).fetchall() + else: + return [] + return [_row(r) for r in rows] + + +@router.post("/controls", status_code=201) +def create_control( + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute(text(""" + INSERT INTO varroa_controls (treatment_id, colony_id, date, result, notes) + VALUES (:treatment_id, :colony_id, :date, :result, :notes) + RETURNING * + """), { + "treatment_id": body.get("treatment_id"), + "colony_id": body.get("colony_id"), + "date": body.get("date"), + "result": body.get("result"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + return _row(row) + + +@router.get("/controls/{id}") +def get_control( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + row = session.execute( + text("SELECT * FROM varroa_controls WHERE id = :id"), {"id": id} + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(row) + + +@router.put("/controls/{id}") +def update_control( + id: int, + body: dict[str, Any], + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +) -> dict: + result = session.execute(text(""" + UPDATE varroa_controls SET date = :date, result = :result, notes = :notes + WHERE id = :id RETURNING * + """), { + "id": id, + "date": body.get("date"), + "result": body.get("result"), + "notes": body.get("notes"), + }).fetchone() + session.commit() + if result is None: + raise HTTPException(status_code=404, detail="Not found") + return _row(result) + + +@router.delete("/controls/{id}", status_code=204) +def delete_control( + id: int, + session: Session = Depends(get_session), + _: str = Depends(get_current_user), +): + session.execute(text("DELETE FROM varroa_controls WHERE id = :id"), {"id": id}) + session.commit() diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..cc60fc1 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlmodel>=0.0.21 +psycopg2-binary>=2.9 +passlib[bcrypt]>=1.7 +bcrypt>=4.0,<5.0 +python-multipart>=0.0.9 diff --git a/beekeeper/.Rbuildignore b/beekeeper/.Rbuildignore new file mode 100644 index 0000000..91114bf --- /dev/null +++ b/beekeeper/.Rbuildignore @@ -0,0 +1,2 @@ +^.*\.Rproj$ +^\.Rproj\.user$ diff --git a/beekeeper/DESCRIPTION b/beekeeper/DESCRIPTION new file mode 100644 index 0000000..41d5e98 --- /dev/null +++ b/beekeeper/DESCRIPTION @@ -0,0 +1,15 @@ +Package: beekeeper +Title: Bienenstockverwaltung +Version: 0.3.0 +Authors@R: person("Nico", "Friess", email = "nico.friess@googlemail.com", role = c("aut", "cre")) +Description: Shiny-Anwendung zur Verwaltung von Bienenstöcken, Völkern, Durchsichten und Ernten. +License: MIT + file LICENSE +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 +Imports: + bslib, + httr2, + jsonlite, + leaflet, + shiny diff --git a/beekeeper/Dockerfile b/beekeeper/Dockerfile new file mode 100644 index 0000000..7ab245f --- /dev/null +++ b/beekeeper/Dockerfile @@ -0,0 +1,16 @@ +FROM rocker/tidyverse:4.4.2 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install deps from DESCRIPTION before copying the rest (layer cache) +COPY DESCRIPTION /pkg/DESCRIPTION +RUN Rscript -e "remotes::install_deps('/pkg', dependencies = TRUE)" + +COPY . /pkg +RUN R CMD INSTALL /pkg + +EXPOSE 3838 + +CMD ["Rscript", "-e", "beekeeper::run_app()"] diff --git a/beekeeper/NAMESPACE b/beekeeper/NAMESPACE new file mode 100644 index 0000000..4bc34b7 --- /dev/null +++ b/beekeeper/NAMESPACE @@ -0,0 +1,3 @@ +# Generated by roxygen2: do not edit by hand +export(run_app) +import(shiny) diff --git a/beekeeper/NEWS.md b/beekeeper/NEWS.md new file mode 100644 index 0000000..85d1a39 --- /dev/null +++ b/beekeeper/NEWS.md @@ -0,0 +1,15 @@ +# beekeeper 0.3.0 + +- Schnelldurchsicht: alle Völker eines Standes auf einem Screen erfassen (K gesehen, Eier, Brutwaben, Notizen), mit einem Speichern-Klick +- Sammelmaßnahme: Maßnahme (Mittelwand, Fütterung, etc.) in einem Schritt für mehrere Völker anlegen + +# beekeeper 0.2.0 + +- API-Schicht (FastAPI) zwischen App und Datenbank eingeführt; alle DB-Zugriffe laufen jetzt über httr2 +- Performance: Letzte Durchsicht wird per Subquery im Kolonien-Endpoint mitgeliefert (kein N+1 mehr) +- Spracheingabe (Web Speech API, Deutsch) im Notizfeld der Durchsicht — experimentell, nur Chrome +- Logging: JSONL-Logdatei in `./logs/YYYY-MM-DD_log.jsonl` mit Startup-Info, Session-Events und API-Fehlern + +# beekeeper 0.1.0 + +- Initiale Version: Imkerei-App mit Völkern, Ständen, Durchsichten, Varroa, Ernte, Königinnen diff --git a/beekeeper/R/api_client.R b/beekeeper/R/api_client.R new file mode 100644 index 0000000..626fbc4 --- /dev/null +++ b/beekeeper/R/api_client.R @@ -0,0 +1,88 @@ +.api_req <- function(api, path) { + httr2::request(paste0(api$base_url, path)) |> + httr2::req_auth_basic(api$user, api$pass) +} + +.api_check <- function(resp, path) { + status <- httr2::resp_status(resp) + if (status >= 400) { + body <- tryCatch(httr2::resp_body_string(resp), error = function(e) "") + log_error("api_error", path = path, status = status, body = body) + } + httr2::resp_check_status(resp) +} + +api_get <- function(api, path, query = NULL) { + req <- .api_req(api, path) + if (!is.null(query) && length(query) > 0) { + query <- Filter(Negate(is.null), query) + if (length(query) > 0) req <- do.call(httr2::req_url_query, c(list(req), query)) + } + resp <- httr2::req_error(req, is_error = \(r) FALSE) |> httr2::req_perform() + if (httr2::resp_status(resp) == 404) return(data.frame()) + .api_check(resp, path) + result <- httr2::resp_body_json(resp, simplifyVector = TRUE) + if (is.null(result) || length(result) == 0) return(data.frame()) + if (is.data.frame(result)) return(result) + as.data.frame(result, stringsAsFactors = FALSE) +} + +api_get_one <- function(api, path, query = NULL) { + req <- .api_req(api, path) + if (!is.null(query) && length(query) > 0) { + query <- Filter(Negate(is.null), query) + if (length(query) > 0) req <- do.call(httr2::req_url_query, c(list(req), query)) + } + resp <- httr2::req_error(req, is_error = \(r) FALSE) |> httr2::req_perform() + if (httr2::resp_status(resp) %in% c(404, 204)) return(data.frame()) + .api_check(resp, path) + result <- httr2::resp_body_json(resp, simplifyVector = FALSE) + if (is.null(result) || length(result) == 0) return(data.frame()) + as.data.frame(lapply(result, function(x) if (is.null(x)) NA else x), stringsAsFactors = FALSE) +} + +api_post <- function(api, path, body = list()) { + log_debug("api_post", path = path) + resp <- .api_req(api, path) |> + httr2::req_body_json(body) |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + if (httr2::resp_content_type(resp) == "" || httr2::resp_status(resp) == 204) + return(invisible(NULL)) + result <- httr2::resp_body_json(resp, simplifyVector = FALSE) + if (is.null(result)) return(invisible(NULL)) + result +} + +api_put <- function(api, path, body = list()) { + log_debug("api_put", path = path) + resp <- .api_req(api, path) |> + httr2::req_method("PUT") |> + httr2::req_body_json(body) |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + invisible(NULL) +} + +api_patch <- function(api, path, body = list()) { + log_debug("api_patch", path = path) + resp <- .api_req(api, path) |> + httr2::req_method("PATCH") |> + httr2::req_body_json(body) |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + invisible(NULL) +} + +api_delete <- function(api, path) { + log_debug("api_delete", path = path) + resp <- .api_req(api, path) |> + httr2::req_method("DELETE") |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + invisible(NULL) +} diff --git a/beekeeper/R/app.R b/beekeeper/R/app.R new file mode 100644 index 0000000..2009d12 --- /dev/null +++ b/beekeeper/R/app.R @@ -0,0 +1,21 @@ +#' @export +run_app <- function(host = "0.0.0.0", 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) + } + + app <- shiny::shinyApp(ui = ui(), server = server_fn) + shiny::runApp(app, host = host, port = port, launch.browser = FALSE) +} diff --git a/beekeeper/R/db_actions.R b/beekeeper/R/db_actions.R new file mode 100644 index 0000000..327c8f3 --- /dev/null +++ b/beekeeper/R/db_actions.R @@ -0,0 +1,32 @@ +db_actions_list <- function(db, colony_id) { + api_get(db, "/v1/actions", list(colony_id = colony_id)) +} + +db_actions_get <- function(db, id) { + api_get_one(db, paste0("/v1/actions/", id)) +} + +db_actions_insert <- function(db, colony_id, date, type, notes = NA, time = NA, + related_colony_id = NA) { + result <- api_post(db, "/v1/actions", list( + colony_id = colony_id, + date = as.character(date), + type = type, + notes = notes, + time = time, + related_colony_id = related_colony_id + )) + result$id +} + +db_actions_update <- function(db, id, date, type, notes = NA) { + api_put(db, paste0("/v1/actions/", id), list( + date = as.character(date), + type = type, + notes = notes + )) +} + +db_actions_delete <- function(db, id) { + api_delete(db, paste0("/v1/actions/", id)) +} diff --git a/beekeeper/R/db_apiaries.R b/beekeeper/R/db_apiaries.R new file mode 100644 index 0000000..4a06b94 --- /dev/null +++ b/beekeeper/R/db_apiaries.R @@ -0,0 +1,32 @@ +db_apiaries_list <- function(db) { + api_get(db, "/v1/apiaries") +} + +db_apiaries_get <- function(db, id) { + api_get_one(db, paste0("/v1/apiaries/", id)) +} + +db_apiaries_insert <- function(db, name, location_text = NA, lat = NA, lng = NA, notes = NA) { + result <- api_post(db, "/v1/apiaries", list( + name = name, + location_text = location_text, + lat = lat, + lng = lng, + notes = notes + )) + result$id +} + +db_apiaries_update <- function(db, id, name, location_text = NA, lat = NA, lng = NA, notes = NA) { + api_put(db, paste0("/v1/apiaries/", id), list( + name = name, + location_text = location_text, + lat = lat, + lng = lng, + notes = notes + )) +} + +db_apiaries_delete <- function(db, id) { + api_delete(db, paste0("/v1/apiaries/", id)) +} diff --git a/beekeeper/R/db_checklists.R b/beekeeper/R/db_checklists.R new file mode 100644 index 0000000..308563c --- /dev/null +++ b/beekeeper/R/db_checklists.R @@ -0,0 +1,48 @@ +db_checklists_list <- function(db, scope = NULL) { + api_get(db, "/v1/checklists") +} + +db_checklists_get <- function(db, id) { + api_get_one(db, paste0("/v1/checklists/", id)) +} + +db_checklists_insert <- function(db, name, scope = "colony") { + result <- api_post(db, "/v1/checklists", list(name = name, scope = scope)) + result$id +} + +db_checklists_delete <- function(db, id) { + api_delete(db, paste0("/v1/checklists/", id)) +} + +db_checklist_items_list <- function(db, checklist_id) { + api_get(db, paste0("/v1/checklists/", checklist_id, "/items")) +} + +db_checklist_items_insert <- function(db, checklist_id, text, position = 0L) { + result <- api_post(db, paste0("/v1/checklists/", checklist_id, "/items"), list( + text = text, + position = as.integer(position) + )) + result$id +} + +db_checklist_items_delete <- function(db, id) { + api_delete(db, paste0("/v1/checklists/items/", id)) +} + +db_checklist_completions_list <- function(db, checklist_id, scope_id, date = NULL) { + api_get(db, paste0("/v1/checklists/", checklist_id, "/completions"), + list(scope_id = scope_id)) +} + +db_checklist_completions_toggle <- function(db, checklist_id, item_id, + scope_id, date, completed_by = NA) { + result <- api_post(db, paste0("/v1/checklists/", checklist_id, "/completions/toggle"), list( + item_id = item_id, + scope_id = scope_id, + date = as.character(date), + completed_by = completed_by + )) + isTRUE(result$toggled) +} diff --git a/beekeeper/R/db_colonies.R b/beekeeper/R/db_colonies.R new file mode 100644 index 0000000..638ae42 --- /dev/null +++ b/beekeeper/R/db_colonies.R @@ -0,0 +1,52 @@ +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_get <- function(db, id) { + api_get_one(db, paste0("/v1/colonies/", id)) +} + +db_colonies_insert <- function(db, name, apiary_id, status = "wirtschaftsvolk", + parent_colony_id = NA, notes = NA) { + result <- api_post(db, "/v1/colonies", list( + name = name, + apiary_id = apiary_id, + status = status, + parent_colony_id = parent_colony_id, + notes = notes + )) + result$id +} + +db_colonies_update <- function(db, id, name, apiary_id, status, notes = NA) { + api_put(db, paste0("/v1/colonies/", id), list( + name = name, + apiary_id = apiary_id, + status = status, + notes = notes + )) +} + +db_colonies_close <- function(db, id) { + api_post(db, paste0("/v1/colonies/", id, "/close")) +} + +db_colonies_set_queen <- function(db, colony_id, queen_id) { + api_post(db, paste0("/v1/colonies/", colony_id, "/set-queen"), list(queen_id = queen_id)) +} + +db_colony_log <- function(db, colony_id) { + api_get(db, paste0("/v1/colonies/", colony_id, "/log")) +} + +db_colonies_lineage <- function(db, colony_id) { + api_get(db, paste0("/v1/colonies/", colony_id, "/lineage")) +} + +db_colonies_descendants <- function(db, colony_id) { + api_get(db, paste0("/v1/colonies/", colony_id, "/descendants")) +} + +db_colonies_location_history <- function(db, colony_id) { + api_get(db, paste0("/v1/colonies/", colony_id, "/location-history")) +} diff --git a/beekeeper/R/db_harvests.R b/beekeeper/R/db_harvests.R new file mode 100644 index 0000000..e215b4f --- /dev/null +++ b/beekeeper/R/db_harvests.R @@ -0,0 +1,37 @@ +db_harvests_list <- function(db, colony_id = NULL, apiary_id = NULL) { + api_get(db, "/v1/harvests", list(colony_id = colony_id, apiary_id = apiary_id)) +} + +db_harvests_insert <- function(db, date, weight_kg, colony_id = NA, + apiary_id = NA, type = NA, notes = NA, time = NA) { + result <- api_post(db, "/v1/harvests", list( + date = as.character(date), + weight_kg = weight_kg, + colony_id = colony_id, + apiary_id = apiary_id, + type = type, + notes = notes, + time = time + )) + result$id +} + +db_harvests_get <- function(db, id) { + api_get_one(db, paste0("/v1/harvests/", id)) +} + +db_harvests_update <- function(db, id, date, weight_kg, notes = NA) { + api_put(db, paste0("/v1/harvests/", id), list( + date = as.character(date), + weight_kg = weight_kg, + notes = notes + )) +} + +db_harvests_delete <- function(db, id) { + api_delete(db, paste0("/v1/harvests/", id)) +} + +db_harvests_summary <- function(db) { + api_get(db, "/v1/harvests/summary") +} diff --git a/beekeeper/R/db_inspections.R b/beekeeper/R/db_inspections.R new file mode 100644 index 0000000..64989fb --- /dev/null +++ b/beekeeper/R/db_inspections.R @@ -0,0 +1,63 @@ +db_inspections_list <- function(db, colony_id) { + api_get(db, "/v1/inspections", list(colony_id = colony_id)) +} + +db_inspections_get <- function(db, id) { + api_get_one(db, paste0("/v1/inspections/", id)) +} + +db_inspections_insert <- function(db, colony_id, date, inspector = NA, + brood_frames = NA, honey_frames = NA, + food_stores_kg = NA, colony_strength = NA, + queen_seen = FALSE, eggs_seen = FALSE, + queen_cells_count = 0L, notes = NA, + traits = list()) { + result <- api_post(db, "/v1/inspections", list( + colony_id = colony_id, + date = as.character(date), + inspector = inspector, + brood_frames = brood_frames, + honey_frames = honey_frames, + food_stores_kg = food_stores_kg, + colony_strength = colony_strength, + queen_seen = as.integer(queen_seen), + eggs_seen = as.integer(eggs_seen), + queen_cells_count = as.integer(queen_cells_count), + notes = notes, + traits = traits + )) + result$id +} + +db_inspections_traits <- function(db, inspection_id) { + api_get(db, paste0("/v1/inspections/", inspection_id, "/traits")) +} + +db_inspections_update <- function(db, id, date, inspector = NA, + brood_frames = NA, honey_frames = NA, + food_stores_kg = NA, colony_strength = NA, + queen_seen = FALSE, eggs_seen = FALSE, + queen_cells_count = 0L, notes = NA, + traits = list()) { + api_put(db, paste0("/v1/inspections/", id), list( + date = as.character(date), + inspector = inspector, + brood_frames = brood_frames, + honey_frames = honey_frames, + food_stores_kg = food_stores_kg, + colony_strength = colony_strength, + queen_seen = as.integer(queen_seen), + eggs_seen = as.integer(eggs_seen), + queen_cells_count = as.integer(queen_cells_count), + notes = notes, + traits = traits + )) +} + +db_inspections_delete <- function(db, id) { + api_delete(db, paste0("/v1/inspections/", id)) +} + +db_inspections_last <- function(db, colony_id) { + api_get_one(db, "/v1/inspections/last", list(colony_id = colony_id)) +} diff --git a/beekeeper/R/db_migrate.R b/beekeeper/R/db_migrate.R new file mode 100644 index 0000000..07ba648 --- /dev/null +++ b/beekeeper/R/db_migrate.R @@ -0,0 +1 @@ +db_migrate <- function(db) invisible(NULL) diff --git a/beekeeper/R/db_queens.R b/beekeeper/R/db_queens.R new file mode 100644 index 0000000..47dca6f --- /dev/null +++ b/beekeeper/R/db_queens.R @@ -0,0 +1,45 @@ +db_queens_list <- function(db, colony_id = NULL) { + api_get(db, "/v1/queens", list(colony_id = colony_id)) +} + +db_queens_get <- function(db, id) { + api_get_one(db, paste0("/v1/queens/", id)) +} + +db_queens_insert <- function(db, colony_id, year = NA, breed = NA, + origin_colony_id = NA, marked_color = NA, + notes = NA, introduced_at = NA) { + result <- api_post(db, "/v1/queens", list( + colony_id = colony_id, + year = year, + breed = breed, + origin_colony_id = origin_colony_id, + marked_color = marked_color, + notes = notes, + introduced_at = introduced_at + )) + result$id +} + +db_queens_update <- function(db, id, colony_id, year = NA, breed = NA, + origin_colony_id = NA, marked_color = NA, + notes = NA, introduced_at = NA, superseded_at = NA) { + api_put(db, paste0("/v1/queens/", id), list( + colony_id = colony_id, + year = year, + breed = breed, + origin_colony_id = origin_colony_id, + marked_color = marked_color, + notes = notes, + introduced_at = introduced_at, + superseded_at = superseded_at + )) +} + +db_queens_supersede <- function(db, id) { + api_post(db, paste0("/v1/queens/", id, "/supersede")) +} + +db_queens_delete <- function(db, id) { + api_delete(db, paste0("/v1/queens/", id)) +} diff --git a/beekeeper/R/db_varroa.R b/beekeeper/R/db_varroa.R new file mode 100644 index 0000000..5dc3712 --- /dev/null +++ b/beekeeper/R/db_varroa.R @@ -0,0 +1,103 @@ +db_varroa_counts_list <- function(db, colony_id) { + api_get(db, "/v1/varroa/counts", list(colony_id = colony_id)) +} + +db_varroa_counts_insert <- function(db, colony_id, date_from = NA, date, method, count, + notes = NA, time = NA) { + result <- api_post(db, "/v1/varroa/counts", list( + colony_id = colony_id, + date_from = date_from, + date = as.character(date), + method = method, + count = count, + notes = notes, + time = time + )) + result$id +} + +db_varroa_counts_get <- function(db, id) { + api_get_one(db, paste0("/v1/varroa/counts/", id)) +} + +db_varroa_counts_update <- function(db, id, date_from = NA, date, method, count, notes = NA) { + api_put(db, paste0("/v1/varroa/counts/", id), list( + date_from = date_from, + date = as.character(date), + method = method, + count = count, + notes = notes + )) +} + +db_varroa_counts_delete <- function(db, id) { + api_delete(db, paste0("/v1/varroa/counts/", id)) +} + +db_varroa_treatments_list <- function(db, colony_id) { + api_get(db, "/v1/varroa/treatments", list(colony_id = colony_id)) +} + +db_varroa_treatments_insert <- function(db, colony_id, date, product, + method = NA, dosage = NA, notes = NA, time = NA) { + result <- api_post(db, "/v1/varroa/treatments", list( + colony_id = colony_id, + date = as.character(date), + product = product, + method = method, + dosage = dosage, + notes = notes, + time = time + )) + result$id +} + +db_varroa_treatments_get <- function(db, id) { + api_get_one(db, paste0("/v1/varroa/treatments/", id)) +} + +db_varroa_treatments_update <- function(db, id, date, product, method = NA, dosage = NA, notes = NA) { + api_put(db, paste0("/v1/varroa/treatments/", id), list( + date = as.character(date), + product = product, + method = method, + dosage = dosage, + notes = notes + )) +} + +db_varroa_treatments_delete <- function(db, id) { + api_delete(db, paste0("/v1/varroa/treatments/", id)) +} + +db_varroa_controls_list <- function(db, colony_id = NULL, treatment_id = NULL) { + api_get(db, "/v1/varroa/controls", list(colony_id = colony_id, treatment_id = treatment_id)) +} + +db_varroa_controls_insert <- function(db, treatment_id, colony_id, date, + result = NA, notes = NA) { + res <- api_post(db, "/v1/varroa/controls", list( + treatment_id = treatment_id, + colony_id = colony_id, + date = as.character(date), + result = result, + notes = notes + )) + res$id +} + +db_varroa_controls_get <- function(db, id) { + api_get_one(db, paste0("/v1/varroa/controls/", id)) +} + +db_varroa_controls_update <- function(db, id, date, result = NA, notes = NA) { + api_put(db, paste0("/v1/varroa/controls/", id), list( + date = as.character(date), + result = result, + notes = notes + )) +} + +db_varroa_controls_delete <- function(db, id) { + api_delete(db, paste0("/v1/varroa/controls/", id)) +} diff --git a/beekeeper/R/logger.R b/beekeeper/R/logger.R new file mode 100644 index 0000000..d2a8a79 --- /dev/null +++ b/beekeeper/R/logger.R @@ -0,0 +1,41 @@ +.log_env <- new.env(parent = emptyenv()) +.log_env$file <- NULL + +log_init <- function() { + log_dir <- Sys.getenv("LOG_DIR", "/logs") + if (!dir.exists(log_dir)) + dir.create(log_dir, recursive = TRUE, showWarnings = FALSE) + fname <- paste0(format(Sys.Date(), "%Y-%m-%d"), "_log.jsonl") + .log_env$file <- file.path(log_dir, fname) + + v <- tryCatch( + as.character(utils::packageVersion("beekeeper")), + error = function(e) "unknown" + ) + .log_write("INFO", "startup", + version = v, + r_version = paste(R.version$major, R.version$minor, sep = "."), + platform = R.version$platform, + api_url = Sys.getenv("API_URL", "http://localhost:8000") + ) +} + +.log_write <- function(level, msg, ...) { + if (is.null(.log_env$file)) return(invisible(NULL)) + extra <- list(...) + entry <- c( + list( + ts = format(Sys.time(), "%Y-%m-%dT%H:%M:%OS3"), + level = level, + msg = msg + ), + extra + ) + line <- jsonlite::toJSON(entry, auto_unbox = TRUE) + cat(line, "\n", file = .log_env$file, append = TRUE, sep = "") + invisible(NULL) +} + +log_info <- function(msg, ...) .log_write("INFO", msg, ...) +log_debug <- function(msg, ...) .log_write("DEBUG", msg, ...) +log_error <- function(msg, ...) .log_write("ERROR", msg, ...) diff --git a/beekeeper/R/screen_actions.R b/beekeeper/R/screen_actions.R new file mode 100644 index 0000000..5258871 --- /dev/null +++ b/beekeeper/R/screen_actions.R @@ -0,0 +1,164 @@ +screen_actions_ui <- function(id) { + ns <- NS(id) + shiny::div( + section_card(NULL, + shiny::div(class = "row g-2 py-2", + shiny::div(class = "col-6", + shiny::dateInput(ns("date"), "Datum", value = Sys.Date(), language = "de", weekstart = 1) + ), + shiny::div(class = "col-6", + shiny::textInput(ns("time"), "Uhrzeit", + value = format(Sys.time(), "%H:%M"), placeholder = "HH:MM") + ) + ) + ), + section_card(NULL, + shiny::div(class = "py-2", + shiny::selectInput(ns("type"), "Maßnahme *", + choices = c("Maßnahme wählen…" = "", COLONY_ACTION_TYPES) + ), + shiny::uiOutput(ns("extra_ui")), + shiny::textAreaInput(ns("notes"), "Notizen", rows = 2, width = "100%") + ) + ), + shiny::div(class = "d-grid mt-2 mb-5", + shiny::actionButton(ns("save"), "Maßnahme speichern", class = "btn btn-info btn-lg") + ) + ) +} + +screen_actions_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + + observeEvent(nav$screen, { + if (nav$screen == "actions") { + shiny::updateDateInput(session, "date", value = Sys.Date()) + shiny::updateTextInput(session, "time", value = format(Sys.time(), "%H:%M")) + shiny::updateSelectInput(session, "type", selected = "") + shiny::updateTextAreaInput(session, "notes", value = "") + } + }, ignoreInit = TRUE) + + output$extra_ui <- renderUI({ + type <- input$type + if (is.null(type) || type == "") return(NULL) + + if (type %in% c("ablegerbildung", "zwischenbodenableger")) { + all_cols <- db_colonies_list(db, include_closed = FALSE) + co <- db_colonies_get(db, nav$colony_id) + placeholder <- if (nrow(co) > 0) paste0(co$name[1], " – Ableger") else "Name Ableger" + shiny::tagList( + shiny::textInput(ns("new_colony_name"), "Name neuer Ableger *", placeholder = placeholder), + shiny::selectInput(ns("parent_colony"), "Muttervolk", + choices = setNames(all_cols$id, paste0(all_cols$name, " (", all_cols$apiary_name, ")")), + selected = nav$colony_id + ) + ) + + } else if (type == "standwechsel") { + ap <- db_apiaries_list(db) + co <- db_colonies_get(db, nav$colony_id) + shiny::selectInput(ns("new_apiary"), "Neuer Standort", + choices = setNames(ap$id, ap$name), + selected = if (nrow(co) > 0) co$apiary_id[1] else NULL + ) + + } else if (type == "vereinigung") { + all_cols <- db_colonies_list(db, include_closed = FALSE) + other_cols <- all_cols[all_cols$id != nav$colony_id, ] + shiny::tagList( + shiny::div( + class = "alert alert-info py-2 small mb-2", + shiny::icon("info-circle"), " ", + "Das ", shiny::tags$strong("aktuelle Volk"), " ist das führende (behält die Königin). ", + "Das ausgewählte Volk wird aufgelöst und als eingegangen markiert." + ), + shiny::selectInput(ns("absorbed_colony"), "Aufzulösendes Volk *", + choices = c("Volk wählen…" = "", + setNames(other_cols$id, + paste0(other_cols$name, " (", other_cols$apiary_name, ")"))) + ) + ) + + } else { + NULL + } + }) + + observeEvent(input$save, { + req(nav$colony_id, nchar(trimws(input$type %||% "")) > 0) + type <- input$type + + if (type %in% c("ablegerbildung", "zwischenbodenableger")) { + req(!is.null(input$new_colony_name), nchar(trimws(input$new_colony_name)) > 0) + parent_id <- if (!is.null(input$parent_colony) && input$parent_colony != "") + as.integer(input$parent_colony) else nav$colony_id + new_id <- db_colonies_insert(db, + name = trimws(input$new_colony_name), + apiary_id = db_colonies_get(db, parent_id)$apiary_id[1], + status = "ableger", + parent_colony_id = parent_id + ) + db_actions_insert(db, + colony_id = nav$colony_id, + date = input$date, + time = input$time %||% NA, + type = type, + notes = input$notes, + related_colony_id = new_id + ) + nav$colony_id <- new_id + nav$screen <- "colony" + return() + } + + if (type == "vereinigung") { + req(!is.null(input$absorbed_colony), input$absorbed_colony != "") + absorbed_id <- as.integer(input$absorbed_colony) + absorbed <- db_colonies_get(db, absorbed_id) + current <- db_colonies_get(db, nav$colony_id) + db_actions_insert(db, + colony_id = nav$colony_id, + date = input$date, + time = input$time %||% NA, + type = "vereinigung", + notes = paste0("Vereinigt mit: ", absorbed$name[1]), + related_colony_id = absorbed_id + ) + db_actions_insert(db, + colony_id = absorbed_id, + date = input$date, + time = input$time %||% NA, + type = "vereinigung", + notes = paste0("Aufgegangen in: ", current$name[1]), + related_colony_id = nav$colony_id + ) + db_colonies_close(db, absorbed_id) + nav$screen <- "colony" + return() + } + + db_actions_insert(db, + colony_id = nav$colony_id, + date = input$date, + time = input$time %||% NA, + type = type, + notes = input$notes + ) + + if (type == "standwechsel" && !is.null(input$new_apiary)) { + co <- db_colonies_get(db, nav$colony_id) + db_colonies_update(db, + id = nav$colony_id, + name = co$name[1], + apiary_id = as.integer(input$new_apiary), + status = co$status[1], + notes = co$notes[1] + ) + } + + nav$screen <- "colony" + }) + }) +} diff --git a/beekeeper/R/screen_apiaries.R b/beekeeper/R/screen_apiaries.R new file mode 100644 index 0000000..8ab1f1c --- /dev/null +++ b/beekeeper/R/screen_apiaries.R @@ -0,0 +1,209 @@ +screen_apiaries_ui <- function(id) { + ns <- NS(id) + shiny::tagList( + shiny::div(class = "mb-3", style = "border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.15)", + leaflet::leafletOutput(ns("overview_map"), height = "200px", width = "100%") + ), + shiny::uiOutput(ns("cards")), + shiny::br(), + shiny::actionButton(ns("add_btn"), shiny::tagList(shiny::icon("plus"), " Neuer Stand"), + class = "add-dashed btn", style = "width:100%;min-height:80px" + ) + ) +} + +screen_apiaries_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + reload <- reactiveVal(0) + edit_id <- reactiveVal(NULL) + add_coords <- reactiveValues(lat = NA_real_, lng = NA_real_) + edit_coords <- reactiveValues(lat = NA_real_, lng = NA_real_) + + apiaries <- reactive({ reload(); db_apiaries_list(db) }) + + output$cards <- renderUI({ + ap <- apiaries() + if (nrow(ap) == 0) return(shiny::p(class = "text-muted", "Noch keine Bienenstände angelegt.")) + lapply(seq_len(nrow(ap)), function(i) { + row <- ap[i, ] + shiny::div( + class = "card tap-card mb-3", + onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", + ns("select"), row$id), + bslib::card_body( + class = "d-flex justify-content-between align-items-start", + shiny::div( + shiny::tags$strong(class = "fs-5", row$name), + shiny::tags$p(class = "text-muted mb-0 small", + if (!is.na(row$location_text) && nchar(row$location_text) > 0) row$location_text + ) + ), + shiny::div( + class = "d-flex flex-column align-items-end gap-1", + shiny::tags$span(class = "badge bg-secondary", + paste(row$colony_count, "Völker")), + shiny::div( + shiny::actionButton(ns(paste0("edit_", row$id)), shiny::icon("pencil"), + class = "btn btn-sm btn-link p-0 text-muted", + onclick = "event.stopPropagation()" + ) + ) + ) + ) + ) + }) + }) + + observeEvent(input$select, { + nav$apiary_id <- input$select + nav$screen <- "colonies" + }) + + # ---- Overview map ------------------------------------------------------- + output$overview_map <- leaflet::renderLeaflet({ + ap <- apiaries() + m <- leaflet::leaflet(options = leaflet::leafletOptions(zoomControl = FALSE, attributionControl = FALSE)) |> + leaflet::addTiles(group = "Karte") |> + leaflet::addProviderTiles("Esri.WorldImagery", group = "Satellit") |> + leaflet::addLayersControl(baseGroups = c("Karte", "Satellit"), + options = leaflet::layersControlOptions(collapsed = TRUE)) + with_coords <- ap[!is.na(ap$lat) & !is.na(ap$lng), ] + if (nrow(with_coords) > 0) { + m <- leaflet::addMarkers(m, + lng = with_coords$lng, lat = with_coords$lat, + label = with_coords$name, layerId = as.character(with_coords$id), + clusterOptions = leaflet::markerClusterOptions() + ) + } else { + m <- leaflet::setView(m, lng = 10.45, lat = 51.17, zoom = 6) + } + m + }) + observeEvent(input$overview_map_marker_click, { + click <- input$overview_map_marker_click + if (!is.null(click$id)) { + nav$apiary_id <- as.integer(click$id) + nav$screen <- "colonies" + } + }) + + # ---- Add modal ----------------------------------------------------------- + output$add_map <- leaflet::renderLeaflet({ + leaflet::leaflet() |> + leaflet::addTiles(group = "Karte") |> + leaflet::addProviderTiles("Esri.WorldImagery", group = "Satellit") |> + leaflet::addLayersControl(baseGroups = c("Karte", "Satellit"), + options = leaflet::layersControlOptions(collapsed = TRUE)) |> + leaflet::setView(lng = 10.45, lat = 51.17, zoom = 6) + }) + observeEvent(input$add_map_click, { + cl <- input$add_map_click + add_coords$lat <- cl$lat; add_coords$lng <- cl$lng + p <- leaflet::leafletProxy("add_map", session) + leaflet::clearMarkers(p) + leaflet::addMarkers(p, lng = cl$lng, lat = cl$lat) + }) + + observeEvent(input$add_btn, { + add_coords$lat <- NA_real_; add_coords$lng <- NA_real_ + shiny::showModal(shiny::modalDialog( + title = "Neuer Bienenstand", size = "l", + footer = shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_add"), "Anlegen", class = "btn-primary") + ), + shiny::textInput(ns("new_name"), "Name *"), + shiny::textInput(ns("new_loc"), "Standortbeschreibung"), + shiny::tags$p(class = "text-muted small mt-3 mb-1", + shiny::tags$em("Standort auf Karte setzen (optional):")), + leaflet::leafletOutput(ns("add_map"), height = "280px"), + shiny::uiOutput(ns("add_coords_lbl")), + shiny::textAreaInput(ns("new_notes"), "Notizen", rows = 2) + )) + }) + output$add_coords_lbl <- renderUI({ + req(!is.na(add_coords$lat)) + shiny::tags$p(class = "text-muted small mt-1", + sprintf("%.5f, %.5f", add_coords$lat, add_coords$lng)) + }) + observeEvent(input$confirm_add, { + req(nchar(trimws(input$new_name)) > 0) + db_apiaries_insert(db, + name = trimws(input$new_name), location_text = input$new_loc, + lat = add_coords$lat, lng = add_coords$lng, notes = input$new_notes + ) + shiny::removeModal(); reload(reload() + 1L) + }) + + # ---- Edit modal ---------------------------------------------------------- + output$edit_map <- leaflet::renderLeaflet({ + lat <- if (!is.na(edit_coords$lat)) edit_coords$lat else 51.17 + lng <- if (!is.na(edit_coords$lng)) edit_coords$lng else 10.45 + zoom <- if (!is.na(edit_coords$lat)) 13L else 6L + m <- leaflet::leaflet() |> + leaflet::addTiles(group = "Karte") |> + leaflet::addProviderTiles("Esri.WorldImagery", group = "Satellit") |> + leaflet::addLayersControl(baseGroups = c("Karte", "Satellit"), + options = leaflet::layersControlOptions(collapsed = TRUE)) |> + leaflet::setView(lng = lng, lat = lat, zoom = zoom) + if (!is.na(edit_coords$lat)) m <- leaflet::addMarkers(m, lng = lng, lat = lat) + m + }) + observeEvent(input$edit_map_click, { + cl <- input$edit_map_click + edit_coords$lat <- cl$lat; edit_coords$lng <- cl$lng + p <- leaflet::leafletProxy("edit_map", session) + leaflet::clearMarkers(p) + leaflet::addMarkers(p, lng = cl$lng, lat = cl$lat) + }) + + observe({ + ap <- apiaries() + lapply(ap$id, function(aid) { + local({ + lid <- aid + observeEvent(input[[paste0("edit_", lid)]], { + row <- db_apiaries_get(db, lid) + edit_id(lid) + edit_coords$lat <- row$lat %||% NA_real_ + edit_coords$lng <- row$lng %||% NA_real_ + shiny::showModal(shiny::modalDialog( + title = "Stand bearbeiten", size = "l", + footer = shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_edit"), "Speichern", class = "btn-primary"), + shiny::actionButton(ns("confirm_delete"), "Löschen", class = "btn-danger ms-auto") + ), + shiny::textInput(ns("edit_name"), "Name *", value = row$name), + shiny::textInput(ns("edit_loc"), "Standortbeschreibung", value = row$location_text %||% ""), + shiny::tags$p(class = "text-muted small mt-3 mb-1", + shiny::tags$em("Standort auf Karte setzen:")), + leaflet::leafletOutput(ns("edit_map"), height = "280px"), + shiny::uiOutput(ns("edit_coords_lbl")), + shiny::textAreaInput(ns("edit_notes"), "Notizen", rows = 2, value = row$notes %||% "") + )) + }, ignoreInit = TRUE) + }) + }) + }) + output$edit_coords_lbl <- renderUI({ + req(!is.na(edit_coords$lat)) + shiny::tags$p(class = "text-muted small mt-1", + sprintf("%.5f, %.5f", edit_coords$lat, edit_coords$lng)) + }) + observeEvent(input$confirm_edit, { + req(edit_id()) + db_apiaries_update(db, id = edit_id(), + name = trimws(input$edit_name), location_text = input$edit_loc, + lat = edit_coords$lat, lng = edit_coords$lng, notes = input$edit_notes + ) + shiny::removeModal(); reload(reload() + 1L) + }) + observeEvent(input$confirm_delete, { + req(edit_id()) + db_apiaries_delete(db, edit_id()) + shiny::removeModal(); reload(reload() + 1L) + }) + }) +} diff --git a/beekeeper/R/screen_bulk_action.R b/beekeeper/R/screen_bulk_action.R new file mode 100644 index 0000000..806b476 --- /dev/null +++ b/beekeeper/R/screen_bulk_action.R @@ -0,0 +1,109 @@ +ACTION_TYPES <- c( + "Mittelwand geben" = "mittelwand", + "Fütterung" = "futterung", + "Ablegerbildung" = "ablegerbildung", + "Brutentnahme" = "brutentnahme", + "Standwechsel" = "standwechsel", + "Völkervereinigung" = "vereinigung", + "Zwischenbödenableger" = "zwischenbodenableger", + "Sonstiges" = "sonstig" +) + +screen_bulk_action_ui <- function(id) { + ns <- NS(id) + shiny::tagList( + section_card(NULL, + shiny::div(class = "py-2", + shiny::dateInput(ns("date"), "Datum", value = Sys.Date(), language = "de", weekstart = 1), + shiny::selectInput(ns("type"), "Maßnahme", choices = ACTION_TYPES), + shiny::textAreaInput(ns("notes"), "Notizen", rows = 2, width = "100%") + ) + ), + section_card("Völker", + shiny::div(class = "d-flex gap-2 py-2 border-bottom", + shiny::tags$button( + type = "button", class = "btn btn-outline-secondary btn-sm", + onclick = sprintf("Shiny.setInputValue('%s', Date.now(), {priority:'event'})", ns("sel_all")), + "Alle" + ), + shiny::tags$button( + type = "button", class = "btn btn-outline-secondary btn-sm", + onclick = sprintf("Shiny.setInputValue('%s', Date.now(), {priority:'event'})", ns("sel_none")), + "Keine" + ) + ), + shiny::uiOutput(ns("colony_checkboxes")) + ), + shiny::div(class = "d-grid mt-2 mb-5", + shiny::actionButton(ns("save"), "Speichern", class = "btn btn-primary btn-lg") + ) + ) +} + +screen_bulk_action_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + + colonies <- reactive({ + req(nav$apiary_id, nav$screen == "bulk_action") + db_colonies_list(db, apiary_id = nav$apiary_id, include_closed = FALSE) + }) + + colony_choices <- reactive({ + cols <- colonies() + if (nrow(cols) == 0) return(character(0)) + setNames(as.character(cols$id), cols$name) + }) + + output$colony_checkboxes <- renderUI({ + ch <- colony_choices() + if (length(ch) == 0) + return(shiny::p(class = "text-muted py-2", "Keine aktiven Völker.")) + shiny::checkboxGroupInput(ns("selected"), NULL, + choices = ch, selected = ch) + }) + + observeEvent(input$sel_all, { + shiny::updateCheckboxGroupInput(session, "selected", + selected = colony_choices()) + }, ignoreNULL = TRUE, ignoreInit = TRUE) + + observeEvent(input$sel_none, { + shiny::updateCheckboxGroupInput(session, "selected", selected = character(0)) + }, ignoreNULL = TRUE, ignoreInit = TRUE) + + observeEvent(input$save, { + selected <- input$selected + if (is.null(selected) || length(selected) == 0) { + shiny::showNotification("Kein Volk ausgewählt.", type = "warning", duration = 3) + return() + } + + notes <- trimws(input$notes %||% "") + n_saved <- 0L + + for (cid_str in selected) { + cid <- as.integer(cid_str) + tryCatch({ + db_actions_insert(db, + colony_id = cid, + date = input$date, + type = input$type, + notes = if (nchar(notes) > 0) notes else NA_character_ + ) + n_saved <- n_saved + 1L + log_debug("bulk_action_saved", colony_id = cid, type = input$type) + }, error = function(e) { + log_error("bulk_action_error", colony_id = cid, msg = conditionMessage(e)) + }) + } + + if (n_saved > 0) + shiny::showNotification( + sprintf("Maßnahme für %d Volk/Völker gespeichert.", n_saved), + type = "message", duration = 3 + ) + nav$screen <- "colonies" + }) + }) +} diff --git a/beekeeper/R/screen_checklists.R b/beekeeper/R/screen_checklists.R new file mode 100644 index 0000000..4d9682b --- /dev/null +++ b/beekeeper/R/screen_checklists.R @@ -0,0 +1,180 @@ +screen_checklists_ui <- function(id) { + ns <- NS(id) + bslib::navset_card_tab( + bslib::nav_panel("Ausführen", + shiny::div(class = "p-2", + shiny::uiOutput(ns("checklist_sel")), + shiny::uiOutput(ns("run_items")) + ) + ), + bslib::nav_panel("Verwalten", + shiny::div(class = "p-2", + shiny::div(class = "d-flex gap-2 mb-3", + shiny::textInput(ns("new_name"), NULL, placeholder = "Neue Checkliste..."), + shiny::selectInput(ns("new_scope"), NULL, + choices = c("Volk" = "colony", "Stand" = "apiary"), width = "120px"), + shiny::actionButton(ns("add_list"), shiny::icon("plus"), class = "btn btn-primary") + ), + shiny::uiOutput(ns("manage_list")) + ) + ) + ) +} + +screen_checklists_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + reload <- reactiveVal(0) + sel_list <- reactiveVal(NULL) + + checklists <- reactive({ reload(); db_checklists_list(db) }) + + output$checklist_sel <- renderUI({ + cls <- checklists() + if (nrow(cls) == 0) + return(shiny::p(class = "text-muted", "Noch keine Checklisten. Im Tab 'Verwalten' anlegen.")) + shiny::selectInput(ns("run_list"), "Checkliste", + choices = c("Bitte wählen" = "", setNames(cls$id, + paste0("[", cls$scope, "] ", cls$name))) + ) + }) + + output$run_items <- renderUI({ + req(input$run_list, input$run_list != "", nav$colony_id) + lid <- as.integer(input$run_list) + scope_id <- nav$colony_id + date <- as.character(Sys.Date()) + items <- db_checklist_items_list(db, lid) + if (nrow(items) == 0) return(shiny::p(class = "text-muted", "Keine Einträge.")) + done <- db_checklist_completions_list(db, lid, scope_id, date) + + shiny::div(class = "list-group", + lapply(seq_len(nrow(items)), function(i) { + item <- items[i, ] + completed <- item$id %in% done$item_id + shiny::div( + class = paste("list-group-item d-flex align-items-center gap-3 py-3", + if (completed) "list-group-item-success" else ""), + shiny::tags$input( + class = "form-check-input flex-shrink-0", + type = "checkbox", + checked = if (completed) NA else NULL, + style = "width:1.3em;height:1.3em;cursor:pointer", + onclick = sprintf( + "Shiny.setInputValue('%s',{id:%d}, {priority:'event'})", + ns("toggle"), item$id + ) + ), + shiny::span(item$text, + style = if (completed) "text-decoration:line-through;opacity:.6" else "") + ) + }) + ) + }) + + observeEvent(input$toggle, { + req(input$run_list, nav$colony_id) + db_checklist_completions_toggle(db, + checklist_id = as.integer(input$run_list), + item_id = as.integer(input$toggle$id), + scope_id = nav$colony_id, + date = as.character(Sys.Date()) + ) + }) + + # ---- Manage tab --------------------------------------------------------- + output$manage_list <- renderUI({ + cls <- checklists() + if (nrow(cls) == 0) return(shiny::p(class = "text-muted", "Keine Checklisten.")) + lapply(seq_len(nrow(cls)), function(i) { + row <- cls[i, ] + shiny::div(class = "card mb-2", + bslib::card_body( + class = "py-2", + shiny::div(class = "d-flex justify-content-between align-items-center", + shiny::div( + shiny::tags$strong(row$name), + shiny::tags$small(class = "text-muted ms-2", paste0("[", row$scope, "]")) + ), + shiny::div( + shiny::actionButton(ns(paste0("sel_", row$id)), "Einträge", + class = "btn btn-sm btn-outline-secondary me-1"), + shiny::actionButton(ns(paste0("del_list_", row$id)), shiny::icon("trash"), + class = "btn btn-sm btn-outline-danger") + ) + ), + shiny::uiOutput(ns(paste0("items_", row$id))) + ) + ) + }) + }) + + observe({ + cls <- checklists() + lapply(cls$id, function(lid) { + local({ + llid <- lid + observeEvent(input[[paste0("sel_", llid)]], sel_list(llid), ignoreInit = TRUE) + observeEvent(input[[paste0("del_list_", llid)]], { + db_checklists_delete(db, llid) + if (identical(sel_list(), llid)) sel_list(NULL) + reload(reload() + 1L) + }, ignoreInit = TRUE) + }) + }) + }) + + observe({ + lid <- sel_list() + req(lid) + items <- db_checklist_items_list(db, lid) + output[[paste0("items_", lid)]] <- renderUI({ + shiny::div(class = "mt-2", + if (nrow(items) > 0) + shiny::tags$ul(class = "list-unstyled mb-2", + lapply(seq_len(nrow(items)), function(i) { + it <- items[i, ] + shiny::tags$li(class = "d-flex justify-content-between align-items-center small py-1 border-bottom", + it$text, + shiny::actionButton(ns(paste0("del_item_", it$id)), "×", + class = "btn btn-sm btn-link text-danger p-0", + onclick = "event.stopPropagation()" + ) + ) + }) + ), + shiny::div(class = "d-flex gap-2", + shiny::textInput(ns(paste0("item_txt_", lid)), NULL, placeholder = "Neuer Eintrag..."), + shiny::actionButton(ns(paste0("add_item_", lid)), shiny::icon("plus"), + class = "btn btn-outline-primary") + ) + ) + }) + lapply(items$id, function(iid) { + local({ + liid <- iid + observeEvent(input[[paste0("del_item_", liid)]], { + db_checklist_items_delete(db, liid) + reload(reload() + 1L) + }, ignoreInit = TRUE) + }) + }) + observeEvent(input[[paste0("add_item_", lid)]], { + txt <- input[[paste0("item_txt_", lid)]] + req(nchar(trimws(txt)) > 0) + its <- db_checklist_items_list(db, lid) + pos <- if (nrow(its) > 0) max(its$position) + 1L else 1L + db_checklist_items_insert(db, lid, trimws(txt), pos) + shiny::updateTextInput(session, paste0("item_txt_", lid), value = "") + reload(reload() + 1L) + }, ignoreInit = TRUE) + }) + + observeEvent(input$add_list, { + req(nchar(trimws(input$new_name)) > 0) + db_checklists_insert(db, trimws(input$new_name), input$new_scope) + shiny::updateTextInput(session, "new_name", value = "") + reload(reload() + 1L) + }) + }) +} diff --git a/beekeeper/R/screen_colonies.R b/beekeeper/R/screen_colonies.R new file mode 100644 index 0000000..5af15c7 --- /dev/null +++ b/beekeeper/R/screen_colonies.R @@ -0,0 +1,145 @@ +screen_colonies_ui <- function(id) { + ns <- NS(id) + shiny::tagList( + shiny::uiOutput(ns("status_filters")), + shiny::uiOutput(ns("cards")), + shiny::br(), + shiny::div(class = "d-flex gap-2 mb-2", + shiny::actionButton(ns("quick_insp_btn"), + shiny::tagList(shiny::icon("bolt"), " Schnelldurchsicht"), + class = "btn btn-outline-primary w-100" + ), + shiny::actionButton(ns("bulk_action_btn"), + shiny::tagList(shiny::icon("layer-group"), " Sammelmaßnahme"), + class = "btn btn-outline-secondary w-100" + ) + ), + shiny::div(class = "d-flex gap-2 mb-2", + shiny::actionButton(ns("lineage_btn"), + shiny::tagList(shiny::icon("sitemap"), " Stammbaum"), + class = "btn btn-outline-secondary w-100" + ) + ), + shiny::actionButton(ns("add_btn"), shiny::tagList(shiny::icon("plus"), " Neues Volk"), + class = "add-dashed btn", style = "width:100%;min-height:80px" + ) + ) +} + +screen_colonies_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + reload <- reactiveVal(0) + status_filter <- reactiveVal("") + + colonies <- reactive({ + req(nav$apiary_id) + reload() + db_colonies_list(db, apiary_id = nav$apiary_id, include_closed = FALSE) + }) + + output$status_filters <- renderUI({ + statuses <- c("Alle" = "", COLONY_STATUSES[names(COLONY_STATUSES) != "Eingegangen"]) + shiny::div(class = "d-flex gap-2 mb-3 flex-wrap", + lapply(seq_along(statuses), function(i) { + val <- statuses[[i]] + active <- identical(status_filter(), val) + shiny::actionButton( + ns(paste0("sf_", i)), names(statuses)[i], + class = if (active) "btn btn-primary btn-sm" else "btn btn-outline-secondary btn-sm" + ) + }) + ) + }) + + observe({ + statuses <- c("Alle" = "", COLONY_STATUSES[names(COLONY_STATUSES) != "Eingegangen"]) + lapply(seq_along(statuses), function(i) { + local({ + li <- i; val <- statuses[[i]] + observeEvent(input[[paste0("sf_", li)]], status_filter(val), ignoreInit = TRUE) + }) + }) + }) + + filtered <- reactive({ + cols <- colonies() + sf <- status_filter() + if (!is.null(sf) && sf != "") cols[cols$status == sf, ] else cols + }) + + output$cards <- renderUI({ + cols <- filtered() + if (nrow(cols) == 0) return(shiny::p(class = "text-muted", "Keine Völker gefunden.")) + lapply(seq_len(nrow(cols)), function(i) { + row <- cols[i, ] + days_ago <- if (!is.na(row$last_inspection_date)) { + d <- as.integer(Sys.Date() - as.Date(row$last_inspection_date)) + if (d == 0) "heute" else if (d == 1) "gestern" else paste0("vor ", d, " Tagen") + } else "keine Durchsicht" + + shiny::div( + class = "card tap-card mb-2", + onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", + ns("select"), row$id), + bslib::card_body( + class = "d-flex justify-content-between align-items-center py-2", + shiny::div( + shiny::tags$strong(row$name), + shiny::tags$p(class = "text-muted small mb-0", days_ago) + ), + shiny::HTML(colony_status_badge(row$status)) + ) + ) + }) + }) + + observeEvent(input$select, { + nav$colony_id <- input$select + nav$screen <- "colony" + }) + + observeEvent(input$quick_insp_btn, { nav$screen <- "quick_inspection" }) + observeEvent(input$bulk_action_btn, { nav$screen <- "bulk_action" }) + observeEvent(input$lineage_btn, { nav$screen <- "lineage" }) + + # ---- Add colony modal --------------------------------------------------- + observeEvent(input$add_btn, { + cols <- db_colonies_list(db, include_closed = FALSE) + shiny::showModal(shiny::modalDialog( + title = "Neues Volk", + footer = shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_add"), "Anlegen", class = "btn-primary") + ), + shiny::textInput(ns("new_name"), "Name *"), + shiny::selectInput(ns("new_status"), "Status", + choices = COLONY_STATUSES[names(COLONY_STATUSES) != "Eingegangen"], + selected = "wirtschaftsvolk" + ), + shiny::selectInput(ns("new_parent"), "Muttervolk (optional)", + choices = c("–" = "", setNames(cols$id, cols$name)) + ), + shiny::textAreaInput(ns("new_notes"), "Notizen", rows = 2) + )) + }) + + observeEvent(input$confirm_add, { + if (nchar(trimws(input$new_name)) == 0) { + shiny::showNotification("Bitte einen Namen eingeben.", type = "warning", duration = 3) + return() + } + req(nav$apiary_id) + parent <- if (!is.null(input$new_parent) && input$new_parent != "") + as.integer(input$new_parent) else NA + db_colonies_insert(db, + name = trimws(input$new_name), + apiary_id = as.integer(nav$apiary_id), + status = input$new_status, + parent_colony_id = parent, + notes = input$new_notes + ) + shiny::removeModal(); reload(reload() + 1L) + }) + }) +} diff --git a/beekeeper/R/screen_colony.R b/beekeeper/R/screen_colony.R new file mode 100644 index 0000000..815dfe1 --- /dev/null +++ b/beekeeper/R/screen_colony.R @@ -0,0 +1,490 @@ +screen_colony_ui <- function(id) { + ns <- NS(id) + shiny::tagList( + shiny::uiOutput(ns("header_card")), + shiny::div( + class = "row g-2 mb-4", + lapply(list( + list(id = "btn_insp", label = "Durchsicht", icon = "clipboard-list", cls = "btn-primary", screen = "inspection"), + list(id = "btn_varroa", label = "Varroa", icon = "bug", cls = "btn-warning", screen = "varroa"), + list(id = "btn_harvest",label = "Ernte", icon = "jar", cls = "btn-success", screen = "harvest"), + list(id = "btn_queen", label = "Königin", icon = "crown", cls = "btn-info", screen = "queens"), + list(id = "btn_check", label = "Checkliste", icon = "list-check", cls = "btn-secondary", screen = "checklists"), + list(id = "btn_actions", label = "Maßnahmen", icon = "bolt", cls = "btn-info", screen = "actions"), + list(id = "btn_edit", label = "Bearbeiten", icon = "pencil", cls = "btn-outline-secondary", screen = NULL) + ), function(b) { + shiny::div(class = "col-6 col-md-4", + shiny::actionButton(ns(b$id), + shiny::tagList(shiny::icon(b$icon), shiny::tags$br(), b$label), + class = paste("btn action-btn w-100", b$cls) + ) + ) + }) + ), + shiny::uiOutput(ns("activity_log")) + ) +} + +screen_colony_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + reload <- reactiveVal(0) + edit_insp_id <- reactiveVal(NULL) + log_filter <- reactiveVal("alle") + log_sel_type <- reactiveVal(NULL) + log_sel_id <- reactiveVal(NULL) + + observeEvent(nav$screen, { + if (nav$screen == "colony") { + reload(reload() + 1L) + log_filter("alle") + } + }, ignoreInit = TRUE) + + lapply(c("alle", "durchsicht", "varroa", "massnahme", "ernte"), function(f) { + observeEvent(input[[paste0("log_filter_", f)]], { + log_filter(f) + }, ignoreInit = TRUE) + }) + + colony <- reactive({ + req(nav$colony_id); reload() + db_colonies_get(db, nav$colony_id) + }) + + output$header_card <- renderUI({ + co <- colony() + req(nrow(co) > 0) + last <- db_inspections_last(db, nav$colony_id) + bslib::card( + class = "mb-3", + bslib::card_body( + shiny::div(class = "d-flex justify-content-between align-items-start mb-2", + shiny::div( + shiny::tags$p(class = "text-muted small mb-0", co$apiary_name %||% ""), + if (!is.na(co$parent_name)) shiny::tags$p(class = "text-muted small mb-0", + paste("Abstammung:", co$parent_name)) + ), + shiny::uiOutput(ns("status_selector")) + ), + if (nrow(last) > 0) + shiny::div(class = "border-top pt-2 mt-2", + shiny::tags$small(class = "text-muted", + paste0("Letzte Durchsicht: ", last$date[1]), + shiny::tags$br(), + paste0( + if (!is.na(last$brood_frames)) paste(last$brood_frames, "BW") else "", + if (!is.na(last$honey_frames)) paste0(" ", last$honey_frames, " HW") else "", + if (!is.na(last$colony_strength)) paste0(" Stärke ", last$colony_strength) else "", + if (last$queen_seen) " ♛ gesehen" else "" + ) + ) + ) + ) + ) + }) + + output$status_selector <- renderUI({ + co <- colony() + req(nrow(co) > 0) + shiny::div( + class = "d-flex flex-wrap gap-1", + lapply(seq_along(COLONY_STATUSES), function(i) { + s <- COLONY_STATUSES[[i]] + lbl <- names(COLONY_STATUSES)[i] + cls <- if (co$status == s) { + paste("btn btn-sm", paste0("btn-", colony_status_color(s))) + } else "btn btn-sm btn-outline-secondary" + shiny::actionButton(ns(paste0("status_", i)), lbl, class = cls) + }) + ) + }) + + observe({ + lapply(seq_along(COLONY_STATUSES), function(i) { + local({ + li <- i; s <- COLONY_STATUSES[[i]] + observeEvent(input[[paste0("status_", li)]], { + req(nav$colony_id) + if (s == "eingegangen") { + shiny::showModal(shiny::modalDialog( + title = "Volk als eingegangen markieren?", + footer = shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_close"), "Bestätigen", class = "btn-danger") + ), + shiny::p("Das Volk wird als eingegangen markiert.") + )) + } else { + db_colonies_update(db, + id = nav$colony_id, + name = isolate(colony()$name), + apiary_id = isolate(colony()$apiary_id), + status = s, + notes = isolate(colony()$notes) + ) + reload(reload() + 1L) + } + }, ignoreInit = TRUE) + }) + }) + }) + + observeEvent(input$confirm_close, { + db_colonies_close(db, nav$colony_id) + shiny::removeModal() + nav$screen <- "colonies" + }) + + # Action button navigation + observeEvent(input$btn_insp, nav$screen <- "inspection") + observeEvent(input$btn_varroa, nav$screen <- "varroa") + observeEvent(input$btn_harvest, nav$screen <- "harvest") + observeEvent(input$btn_queen, nav$screen <- "queens") + observeEvent(input$btn_check, nav$screen <- "checklists") + observeEvent(input$btn_actions, nav$screen <- "actions") + + # Edit colony modal + observeEvent(input$btn_edit, { + co <- colony() + ap <- db_apiaries_list(db) + shiny::showModal(shiny::modalDialog( + title = paste("Volk bearbeiten:", co$name), + footer = shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_edit"), "Speichern", class = "btn-primary") + ), + shiny::textInput(ns("edit_name"), "Name *", value = co$name), + shiny::selectInput(ns("edit_apiary"), "Bienenstand", + choices = setNames(ap$id, ap$name), + selected = co$apiary_id + ), + shiny::textAreaInput(ns("edit_notes"), "Notizen", rows = 2, + value = co$notes %||% "") + )) + }) + observeEvent(input$confirm_edit, { + req(nchar(trimws(input$edit_name)) > 0) + co <- isolate(colony()) + db_colonies_update(db, + id = nav$colony_id, + name = trimws(input$edit_name), + apiary_id = as.integer(input$edit_apiary), + status = co$status, + notes = input$edit_notes + ) + shiny::removeModal(); reload(reload() + 1L) + }) + + output$activity_log <- renderUI({ + reload() + req(nav$colony_id) + log <- db_colony_log(db, nav$colony_id) + flt <- log_filter() + + type_label <- c( + durchsicht = "Durchsicht", + kontrolle = "Kontrolle", + behandlung = "Behandlung", + nachkontrolle = "Nachkontrolle", + ernte = "Ernte", + massnahme = "Maßnahme" + ) + type_badge <- c( + durchsicht = "bg-primary", + kontrolle = "bg-warning text-dark", + behandlung = "bg-danger", + nachkontrolle = "bg-secondary", + ernte = "bg-success", + massnahme = "bg-info text-dark" + ) + + filter_cats <- list( + list(id = "alle", label = "Alle"), + list(id = "durchsicht", label = "Durchsicht"), + list(id = "varroa", label = "Varroa"), + list(id = "massnahme", label = "Maßnahmen"), + list(id = "ernte", label = "Ernte") + ) + chips <- shiny::div(class = "d-flex flex-wrap gap-1 px-3 pt-3 pb-2", + lapply(filter_cats, function(f) { + cls <- if (flt == f$id) "btn btn-sm btn-primary" else "btn btn-sm btn-outline-secondary" + shiny::actionButton(ns(paste0("log_filter_", f$id)), f$label, class = cls) + }) + ) + + if (nrow(log) > 0 && flt != "alle") { + keep <- switch(flt, + durchsicht = log$type == "durchsicht", + varroa = log$type %in% c("kontrolle", "behandlung", "nachkontrolle"), + massnahme = log$type == "massnahme", + ernte = log$type == "ernte", + rep(TRUE, nrow(log)) + ) + log <- log[keep, , drop = FALSE] + } + + items <- if (nrow(log) == 0) { + shiny::div(class = "px-3 py-2 text-muted small", "Keine Einträge.") + } else { + shiny::tags$ul(class = "list-group list-group-flush", + lapply(seq_len(nrow(log)), function(i) { + r <- log[i, ] + is_insp <- r$type == "durchsicht" + onclick_val <- if (is_insp) + sprintf("Shiny.setInputValue('%s', %s, {priority:'event'})", + ns("insp_select"), r$ref_id) + else + sprintf("Shiny.setInputValue('%s', '%s:%s', {priority:'event'})", + ns("log_select"), r$type, r$ref_id) + shiny::tags$li( + class = "list-group-item py-2 px-3 tap-card", + onclick = onclick_val, + shiny::div(class = "d-flex justify-content-between align-items-center gap-2", + shiny::div(class = "d-flex align-items-center gap-2 min-width-0", + shiny::tags$span(class = paste("badge flex-shrink-0", type_badge[r$type]), + type_label[r$type]), + shiny::tags$span(class = "small text-truncate", r$summary) + ), + shiny::div(class = "d-flex align-items-center gap-1 flex-shrink-0", + shiny::tags$span(class = "text-muted small", r$date), + shiny::icon("chevron-right", class = "text-muted small") + ) + ) + ) + }) + ) + } + + bslib::card( + class = "mb-3", + bslib::card_header("Aktivitätslog"), + bslib::card_body(class = "p-0", chips, items) + ) + }) + + observeEvent(input$insp_select, { + id <- as.integer(input$insp_select) + insp <- db_inspections_get(db, id) + req(nrow(insp) > 0) + traits_df <- db_inspections_traits(db, id) + get_trait <- function(tval) { + s <- traits_df$score[traits_df$trait == tval] + if (length(s) > 0 && !is.na(s[1])) as.character(s[1]) else "" + } + edit_insp_id(id) + shiny::showModal(shiny::modalDialog( + title = paste("Durchsicht", insp$date), + size = "l", + footer = shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_insp_edit"), "Speichern", class = "btn-primary"), + shiny::actionButton(ns("confirm_insp_delete"), "Löschen", class = "btn-danger ms-auto") + ), + shiny::div(class = "row g-2", + shiny::div(class = "col-6", + shiny::dateInput(ns("ei_date"), "Datum", value = as.Date(insp$date), language = "de", weekstart = 1) + ), + shiny::div(class = "col-6", + shiny::textInput(ns("ei_inspector"), "Imker/in", value = insp$inspector %||% "") + ) + ), + shiny::div(class = "row g-2 mt-1", + shiny::div(class = "col-4", + shiny::numericInput(ns("ei_brood"), "Brutwaben", value = insp$brood_frames, min = 0, step = 1) + ), + shiny::div(class = "col-4", + shiny::numericInput(ns("ei_honey"), "Honigwaben", value = insp$honey_frames, min = 0, step = 1) + ), + shiny::div(class = "col-4", + shiny::numericInput(ns("ei_food"), "Futter (kg)", value = insp$food_stores_kg, min = 0, step = 0.5) + ) + ), + shiny::div(class = "row g-2 mt-1", + shiny::div(class = "col-6", + shiny::selectInput(ns("ei_strength"), "Volksstärke", + choices = c("–" = "", setNames(as.character(1:9), 1:9)), + selected = if (!is.na(insp$colony_strength)) as.character(insp$colony_strength) else "" + ) + ), + shiny::div(class = "col-6", + shiny::numericInput(ns("ei_qcells"), "Weiselzellen", value = insp$queen_cells_count %||% 0, min = 0, step = 1) + ) + ), + shiny::div(class = "d-flex gap-4 mt-2 mb-2", + shiny::checkboxInput(ns("ei_queen"), "Königin gesehen", value = isTRUE(insp$queen_seen == 1)), + shiny::checkboxInput(ns("ei_eggs"), "Eier / Stifte gesehen", value = isTRUE(insp$eggs_seen == 1)) + ), + shiny::div(class = "row g-2", + lapply(names(TRAITS), function(tname) { + tval <- TRAITS[[tname]] + shiny::div(class = "col-6", + shiny::selectInput(ns(paste0("ei_t_", tval)), tname, + choices = c("–" = "", setNames(as.character(1:4), 1:4)), + selected = get_trait(tval) + ) + ) + }) + ), + shiny::textAreaInput(ns("ei_notes"), "Notizen / Auffälligkeiten", + value = insp$notes %||% "", rows = 3, width = "100%") + )) + }) + + observeEvent(input$confirm_insp_edit, { + id <- edit_insp_id(); req(id) + traits <- setNames( + lapply(TRAITS, function(tval) { + v <- input[[paste0("ei_t_", tval)]] + if (!is.null(v) && v != "") as.integer(v) else NA_integer_ + }), + TRAITS + ) + db_inspections_update(db, + id = id, + date = input$ei_date, + inspector = input$ei_inspector, + brood_frames = input$ei_brood, + honey_frames = input$ei_honey, + food_stores_kg = input$ei_food, + colony_strength = if (!is.null(input$ei_strength) && input$ei_strength != "") + as.integer(input$ei_strength) else NA_integer_, + queen_seen = isTRUE(input$ei_queen), + eggs_seen = isTRUE(input$ei_eggs), + queen_cells_count = as.integer(input$ei_qcells %||% 0L), + notes = input$ei_notes, + traits = traits + ) + shiny::removeModal(); reload(reload() + 1L) + }) + + observeEvent(input$confirm_insp_delete, { + id <- edit_insp_id(); req(id) + db_inspections_delete(db, id) + shiny::removeModal(); reload(reload() + 1L) + }) + + observeEvent(input$log_select, { + parts <- strsplit(input$log_select, ":", fixed = TRUE)[[1]] + type <- parts[1] + id <- as.integer(parts[2]) + log_sel_type(type); log_sel_id(id) + + footer <- shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_log_save"), "Speichern", class = "btn-primary"), + shiny::actionButton(ns("confirm_log_delete"), "Löschen", class = "btn-danger ms-auto") + ) + + if (type == "kontrolle") { + vc <- db_varroa_counts_get(db, id); req(nrow(vc) > 0) + shiny::showModal(shiny::modalDialog( + title = paste("Varroa-Kontrolle", vc$date), footer = footer, + shiny::div(class = "row g-2", + shiny::div(class = "col-6", + shiny::dateInput(ns("el_date_from"), "Von", + value = if (!is.na(vc$date_from)) as.Date(vc$date_from) else NA, + language = "de", weekstart = 1) + ), + shiny::div(class = "col-6", + shiny::dateInput(ns("el_date"), "Bis", + value = as.Date(vc$date), language = "de", weekstart = 1) + ) + ), + shiny::numericInput(ns("el_count"), "Anzahl Milben", value = vc$count, min = 0, step = 1, width = "100%"), + shiny::selectInput(ns("el_method"), "Methode", choices = VARROA_METHODS, selected = vc$method), + shiny::textAreaInput(ns("el_notes"), "Notizen", value = vc$notes %||% "", rows = 2, width = "100%") + )) + + } else if (type == "behandlung") { + vt <- db_varroa_treatments_get(db, id); req(nrow(vt) > 0) + shiny::showModal(shiny::modalDialog( + title = paste("Varroa-Behandlung", vt$date), footer = footer, + shiny::dateInput(ns("el_date"), "Datum", value = as.Date(vt$date), language = "de", weekstart = 1), + shiny::selectizeInput(ns("el_product"), "Mittel", + choices = c("Mittel wählen…" = "", VARROA_PRODUCTS), + selected = vt$product, options = list(create = TRUE)), + shiny::textInput(ns("el_method_txt"), "Anwendung", value = vt$method %||% ""), + shiny::textInput(ns("el_dosage"), "Dosierung", value = vt$dosage %||% ""), + shiny::textAreaInput(ns("el_notes"), "Notizen", value = vt$notes %||% "", rows = 2, width = "100%") + )) + + } else if (type == "nachkontrolle") { + vc2 <- db_varroa_controls_get(db, id); req(nrow(vc2) > 0) + shiny::showModal(shiny::modalDialog( + title = paste("Nachkontrolle", vc2$date), footer = footer, + shiny::dateInput(ns("el_date"), "Datum", value = as.Date(vc2$date), language = "de", weekstart = 1), + shiny::textInput(ns("el_result"), "Ergebnis / Befund", value = vc2$result %||% ""), + shiny::textAreaInput(ns("el_notes"), "Notizen", value = vc2$notes %||% "", rows = 2, width = "100%") + )) + + } else if (type == "ernte") { + h <- db_harvests_get(db, id); req(nrow(h) > 0) + shiny::showModal(shiny::modalDialog( + title = paste("Honigernte", h$date), footer = footer, + shiny::dateInput(ns("el_date"), "Datum", value = as.Date(h$date), language = "de", weekstart = 1), + shiny::numericInput(ns("el_weight"), "Gewicht (kg)", value = h$weight_kg, min = 0, step = 0.1, width = "100%"), + shiny::textAreaInput(ns("el_notes"), "Notizen", value = h$notes %||% "", rows = 2, width = "100%") + )) + + } else if (type == "massnahme") { + ac <- db_actions_get(db, id); req(nrow(ac) > 0) + shiny::showModal(shiny::modalDialog( + title = paste("Maßnahme", ac$date), footer = footer, + shiny::dateInput(ns("el_date"), "Datum", value = as.Date(ac$date), language = "de", weekstart = 1), + shiny::selectInput(ns("el_action_type"), "Maßnahme", + choices = COLONY_ACTION_TYPES, selected = ac$type), + shiny::textAreaInput(ns("el_notes"), "Notizen", value = ac$notes %||% "", rows = 2, width = "100%") + )) + } + }) + + observeEvent(input$confirm_log_save, { + type <- log_sel_type(); id <- log_sel_id(); req(type, id) + if (type == "kontrolle") { + db_varroa_counts_update(db, id, + date_from = input$el_date_from, + date = input$el_date, + method = input$el_method, + count = input$el_count, + notes = input$el_notes) + } else if (type == "behandlung") { + db_varroa_treatments_update(db, id, + date = input$el_date, + product = input$el_product, + method = input$el_method_txt, + dosage = input$el_dosage, + notes = input$el_notes) + } else if (type == "nachkontrolle") { + db_varroa_controls_update(db, id, + date = input$el_date, + result = input$el_result, + notes = input$el_notes) + } else if (type == "ernte") { + db_harvests_update(db, id, + date = input$el_date, + weight_kg = input$el_weight, + notes = input$el_notes) + } else if (type == "massnahme") { + db_actions_update(db, id, + date = input$el_date, + type = input$el_action_type, + notes = input$el_notes) + } + shiny::removeModal(); reload(reload() + 1L) + }) + + observeEvent(input$confirm_log_delete, { + type <- log_sel_type(); id <- log_sel_id(); req(type, id) + switch(type, + kontrolle = db_varroa_counts_delete(db, id), + behandlung = db_varroa_treatments_delete(db, id), + nachkontrolle = db_varroa_controls_delete(db, id), + ernte = db_harvests_delete(db, id), + massnahme = db_actions_delete(db, id) + ) + shiny::removeModal(); reload(reload() + 1L) + }) + + }) +} diff --git a/beekeeper/R/screen_harvest.R b/beekeeper/R/screen_harvest.R new file mode 100644 index 0000000..59f1e65 --- /dev/null +++ b/beekeeper/R/screen_harvest.R @@ -0,0 +1,59 @@ +screen_harvest_ui <- function(id) { + ns <- NS(id) + shiny::div( + section_card(NULL, + shiny::div(class = "row g-2 py-2", + shiny::div(class = "col-6", + shiny::dateInput(ns("date"), "Datum", value = Sys.Date(), language = "de", weekstart = 1) + ), + shiny::div(class = "col-6", + shiny::textInput(ns("time"), "Uhrzeit", + value = format(Sys.time(), "%H:%M"), placeholder = "HH:MM") + ) + ) + ), + section_card(NULL, + shiny::div( + class = "d-flex justify-content-between align-items-center py-3 border-bottom", + shiny::span("Gewicht (kg) *", class = "fw-semibold"), + shiny::numericInput(ns("weight_kg"), NULL, value = NA, min = 0, step = 0.1, width = "110px") + ), + btn_group_row(ns, "type", "Honigsorte", HONEY_TYPES) + ), + section_card(NULL, + shiny::div(class = "py-2", + shiny::textAreaInput(ns("notes"), "Notizen", rows = 2, width = "100%") + ) + ), + shiny::div(class = "d-grid mt-2 mb-5", + shiny::actionButton(ns("save"), "Ernte speichern", class = "btn btn-success btn-lg") + ) + ) +} + +screen_harvest_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + type_val <- btn_group_server("type", ns, input, output, HONEY_TYPES) + + observeEvent(nav$screen, { + if (nav$screen == "harvest") { + type_val(NA) + shiny::updateTextInput(session, "time", value = format(Sys.time(), "%H:%M")) + } + }, ignoreInit = TRUE) + + observeEvent(input$save, { + req(nav$colony_id, !is.na(input$weight_kg), input$weight_kg > 0) + db_harvests_insert(db, + date = input$date, + weight_kg = input$weight_kg, + colony_id = nav$colony_id, + type = type_val(), + notes = input$notes, + time = input$time %||% NA + ) + nav$screen <- "colony" + }) + }) +} diff --git a/beekeeper/R/screen_inspection.R b/beekeeper/R/screen_inspection.R new file mode 100644 index 0000000..8088ba9 --- /dev/null +++ b/beekeeper/R/screen_inspection.R @@ -0,0 +1,255 @@ +INSP_INFO <- list( + futtervorrat = list( + title = "Futtervorrat schätzen", + content = shiny::tagList( + shiny::tags$p("Zarge wiegen und Leergewicht abziehen."), + shiny::tags$p(shiny::tags$strong("Richtwerte Leergewicht (Holz):")), + shiny::tags$table(class = "table table-sm table-borderless mb-0", + shiny::tags$tbody( + shiny::tags$tr(shiny::tags$td("Zander Honigraum"), shiny::tags$td("ca. 5–6 kg")), + shiny::tags$tr(shiny::tags$td("Zander Brutraum"), shiny::tags$td("ca. 7–8 kg")), + shiny::tags$tr(shiny::tags$td("Dadant Brutraum"), shiny::tags$td("ca. 8–10 kg")) + ) + ), + shiny::tags$p(class = "mt-2 mb-0", "1 voll verdeckelter Rahmen ≈ 2–3 kg."), + shiny::tags$p(class = "mb-0 text-muted small", "Winterbedarf: ca. 15–20 kg / Volk.") + ) + ), + sanftmut = list( + title = "Sanftmut (1–4)", + content = shiny::tagList( + shiny::tags$p(class = "mb-1", "Verhalten während der Durchsicht", shiny::tags$em("(niedriger = besser):")), + shiny::tags$table(class = "table table-sm table-borderless mb-0", + shiny::tags$tbody( + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("1")), shiny::tags$td("sehr sanft – kein Stichversuch")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("2")), shiny::tags$td("sanft – vereinzelte Stichversuche")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("3")), shiny::tags$td("unruhig – mehrere Stichversuche")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("4")), shiny::tags$td("aggressiv – folgt dem Imker")) + ) + ) + ) + ), + schwarmtrieb = list( + title = "Schwarmtrieb (1–4)", + content = shiny::tagList( + shiny::tags$p(class = "mb-1", "Neigung zur Schwarmbildung", shiny::tags$em("(niedriger = besser):")), + shiny::tags$table(class = "table table-sm table-borderless mb-0", + shiny::tags$tbody( + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("1")), shiny::tags$td("kein Schwarmtrieb")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("2")), shiny::tags$td("gering – vereinzelte Zellen")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("3")), shiny::tags$td("erhöht – mehrere Zellen")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("4")), shiny::tags$td("stark – Schwarm unmittelbar")) + ) + ) + ) + ), + wabensitz = list( + title = "Wabensitz (1–4)", + content = shiny::tagList( + shiny::tags$p(class = "mb-1", "Ruhigkeit auf der Wabe", shiny::tags$em("(niedriger = besser):")), + shiny::tags$table(class = "table table-sm table-borderless mb-0", + shiny::tags$tbody( + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("1")), shiny::tags$td("sehr ruhig – Bienen sitzen fest")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("2")), shiny::tags$td("leicht unruhig")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("3")), shiny::tags$td("unruhig – Traubenbildung")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("4")), shiny::tags$td("sehr unruhig – starkes Laufen")) + ) + ) + ) + ), + honigertrag = list( + title = "Honigertrag (1–4)", + content = shiny::tagList( + shiny::tags$p(class = "mb-1", "Honigleistung relativ zur Standpopulation", shiny::tags$em("(niedriger = besser):")), + shiny::tags$table(class = "table table-sm table-borderless mb-0", + shiny::tags$tbody( + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("1")), shiny::tags$td("überdurchschnittlich")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("2")), shiny::tags$td("durchschnittlich")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("3")), shiny::tags$td("unterdurchschnittlich")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("4")), shiny::tags$td("sehr gering")) + ) + ) + ) + ), + varroa = list( + title = "Varroa-Hygiene (1–4)", + content = shiny::tagList( + shiny::tags$p(class = "mb-1", "VSH-Verhalten (Varroa Sensitive Hygiene)", shiny::tags$em("(niedriger = besser):")), + shiny::tags$table(class = "table table-sm table-borderless mb-0", + shiny::tags$tbody( + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("1")), shiny::tags$td("sehr stark – räumt Varroazellen aus")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("2")), shiny::tags$td("erkennbar")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("3")), shiny::tags$td("schwach erkennbar")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("4")), shiny::tags$td("nicht erkennbar")) + ) + ), + shiny::tags$p(class = "mt-2 mb-0 text-muted small", "Tipp: Alkoholwäsche gibt verlässlichere Werte.") + ) + ) +) + +screen_inspection_ui <- function(id) { + ns <- NS(id) + shiny::div( + section_card(NULL, + shiny::div(class = "row g-2 py-2", + shiny::div(class = "col-6", + shiny::dateInput(ns("date"), "Datum", value = Sys.Date(), language = "de", weekstart = 1) + ), + shiny::div(class = "col-6", + shiny::textInput(ns("inspector"), "Imker/in") + ) + ) + ), + section_card("Stockkarte", + stepper_row(ns, "brood", "Brutwaben"), + stepper_row(ns, "honey", "Honigwaben"), + strength_row(ns), + shiny::div( + class = "d-flex justify-content-between align-items-center py-3 border-bottom", + shiny::span(class = "fw-semibold", + "Futtervorrat (kg)", + info_icon(INSP_INFO$futtervorrat$title, INSP_INFO$futtervorrat$content) + ), + shiny::numericInput(ns("food_kg"), NULL, value = NA, min = 0, step = 0.5, width = "100px") + ) + ), + section_card("Königin", + toggle_row(ns, "queen_seen", "Königin gesehen"), + toggle_row(ns, "eggs_seen", "Eier / Stifte gesehen"), + stepper_row(ns, "qcells", "Weiselzellen") + ), + section_card(NULL, + shiny::div(class = "py-2", + shiny::div(class = "d-flex justify-content-between align-items-center mb-1", + shiny::tags$label("Notizen / Auffälligkeiten", class = "form-label mb-0"), + shiny::div(class = "d-flex align-items-center gap-2", + shiny::tags$small(id = ns("mic_status"), class = "text-muted", ""), + shiny::tags$button( + id = ns("mic_btn"), + type = "button", + class = "btn btn-outline-secondary btn-sm", + title = "Spracheingabe (Deutsch)", + "\U0001f3a4" + ) + ) + ), + shiny::textAreaInput(ns("notes"), NULL, rows = 3, width = "100%"), + shiny::tags$script(shiny::HTML(sprintf(' + setTimeout(function() { + var SR = window.SpeechRecognition || window.webkitSpeechRecognition; + var btn = document.getElementById("%s"); + var sts = document.getElementById("%s"); + if (!SR) { if (btn) btn.style.display = "none"; return; } + var rec = null; + var active = false; + + function setIdle() { + active = false; + btn.classList.replace("btn-danger", "btn-outline-secondary"); + btn.textContent = "\U0001f3a4"; + sts.textContent = ""; + } + function setRecording() { + active = true; + btn.classList.replace("btn-outline-secondary", "btn-danger"); + btn.textContent = "⏹"; + sts.textContent = "Aufnahme..."; + } + + btn.addEventListener("click", function() { + if (active) { + rec.stop(); + } else { + rec = new SR(); + rec.lang = "de-DE"; + rec.continuous = false; + rec.interimResults = false; + + rec.onstart = function() { setRecording(); }; + rec.onresult = function(e) { + var t = Array.from(e.results) + .filter(function(r) { return r.isFinal; }) + .map(function(r) { return r[0].transcript; }) + .join(" ").trim(); + if (!t) return; + var ta = document.getElementById("%s"); + ta.value = ta.value ? ta.value + " " + t : t; + Shiny.setInputValue("%s", ta.value, {priority: "event"}); + }; + rec.onerror = function(e) { + sts.textContent = "Fehler: " + e.error; + setTimeout(setIdle, 2000); + }; + rec.onend = function() { setIdle(); }; + rec.start(); + } + }); + }, 200); + ', ns("mic_btn"), ns("mic_status"), ns("notes"), ns("notes")))) + ) + ), + collapsible_card("Zuchteigenschaften (optional)", + trait_row(ns, "t_sanft", "Sanftmut", info = INSP_INFO$sanftmut), + trait_row(ns, "t_schwarm", "Schwarmtrieb", info = INSP_INFO$schwarmtrieb), + trait_row(ns, "t_waben", "Wabensitz", info = INSP_INFO$wabensitz), + trait_row(ns, "t_honig", "Honigertrag", info = INSP_INFO$honigertrag), + trait_row(ns, "t_varroa", "Varroa-Hygiene", info = INSP_INFO$varroa), + collapse_id = "insp_zuchteig" + ), + shiny::div(class = "d-grid mt-2 mb-5", + shiny::actionButton(ns("save"), "Durchsicht speichern", + class = "btn btn-primary btn-lg") + ) + ) +} + +screen_inspection_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + + brood <- stepper_server("brood", input, output) + honey <- stepper_server("honey", input, output) + qcells <- stepper_server("qcells", input, output, default = 0L) + str_v <- strength_server("strength", ns, input, output) + + t_sanft <- trait_server("t_sanft", ns, input, output) + t_schwarm <- trait_server("t_schwarm", ns, input, output) + t_waben <- trait_server("t_waben", ns, input, output) + t_honig <- trait_server("t_honig", ns, input, output) + t_varroa <- trait_server("t_varroa", ns, input, output) + + observeEvent(nav$screen, { + if (nav$screen == "inspection") { + brood(NA_integer_); honey(NA_integer_); qcells(0L); str_v(NA_integer_) + t_sanft(NA_integer_); t_schwarm(NA_integer_); t_waben(NA_integer_) + t_honig(NA_integer_); t_varroa(NA_integer_) + } + }, ignoreInit = TRUE) + + observeEvent(input$save, { + req(nav$colony_id) + db_inspections_insert(db, + colony_id = nav$colony_id, + date = input$date, + inspector = input$inspector, + brood_frames = brood(), + honey_frames = honey(), + food_stores_kg = input$food_kg, + colony_strength = str_v(), + queen_seen = isTRUE(input$queen_seen), + eggs_seen = isTRUE(input$eggs_seen), + queen_cells_count = qcells() %||% 0L, + notes = input$notes, + traits = list( + sanftmut = t_sanft(), + schwarmtrieb = t_schwarm(), + wabensitz = t_waben(), + honigertrag = t_honig(), + varroa_hygiene = t_varroa() + ) + ) + nav$screen <- "colony" + }) + }) +} diff --git a/beekeeper/R/screen_lineage.R b/beekeeper/R/screen_lineage.R new file mode 100644 index 0000000..8c4fdfd --- /dev/null +++ b/beekeeper/R/screen_lineage.R @@ -0,0 +1,95 @@ +lineage_render_node <- function(ns, co, all_cols) { + children <- all_cols[ + !is.na(all_cols$parent_colony_id) & all_cols$parent_colony_id == co$id, ] + children <- children[order(children$name), ] + + is_closed <- !is.na(co$closed_at) + apiary_lbl <- co$apiary_name %||% "–" + queen_lbl <- if (!is.na(co$queen_year) && co$queen_year != "") + paste0(" · Kgn. ", co$queen_year) else "" + + card <- shiny::div( + class = paste("card tap-card mb-2", if (is_closed) "opacity-60 border-secondary" else ""), + onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", ns("select"), co$id), + bslib::card_body( + class = "d-flex justify-content-between align-items-center py-2 px-3", + shiny::div( + shiny::tags$strong(class = if (is_closed) "text-muted" else "", co$name), + shiny::tags$p(class = "text-muted small mb-0", + paste0(apiary_lbl, queen_lbl)) + ), + shiny::HTML(colony_status_badge(co$status)) + ) + ) + + if (nrow(children) == 0) { + card + } else { + shiny::tagList( + card, + shiny::div( + class = "ms-4 ps-2 border-start border-2 border-secondary-subtle mb-1", + lapply(seq_len(nrow(children)), function(i) + lineage_render_node(ns, children[i, ], all_cols)) + ) + ) + } +} + +screen_lineage_ui <- function(id) { + ns <- NS(id) + shiny::div( + shiny::div( + class = "d-flex gap-2 mb-3", + shiny::checkboxInput(ns("show_closed"), "Eingegangene Völker anzeigen", value = FALSE) + ), + shiny::uiOutput(ns("tree")) + ) +} + +screen_lineage_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + + all_cols <- reactive({ + req(nav$screen == "lineage") + db_colonies_list(db, include_closed = TRUE) + }) + + output$tree <- renderUI({ + cols <- all_cols() + if (!isTRUE(input$show_closed)) + cols <- cols[is.na(cols$closed_at), ] + + if (nrow(cols) == 0) + return(shiny::p(class = "text-muted", "Keine Völker vorhanden.")) + + all_ids <- cols$id + is_root <- is.na(cols$parent_colony_id) | !(cols$parent_colony_id %in% all_ids) + roots <- cols[is_root, ] + roots <- roots[order(roots$apiary_name, roots$name), ] + + cur_apiary <- NULL + out <- list() + for (i in seq_len(nrow(roots))) { + ro <- roots[i, ] + ap <- ro$apiary_name %||% "–" + if (!identical(ap, cur_apiary)) { + out <- c(out, list(shiny::tags$p(class = "text-muted small fw-semibold mt-3 mb-1", ap))) + cur_apiary <- ap + } + out <- c(out, list(lineage_render_node(ns, ro, cols))) + } + shiny::tagList(out) + }) + + observeEvent(input$select, { + id <- as.integer(input$select) + cols <- isolate(all_cols()) + co <- cols[cols$id == id, ] + if (nrow(co) > 0) nav$apiary_id <- co$apiary_id[1] + nav$colony_id <- id + nav$screen <- "colony" + }) + }) +} diff --git a/beekeeper/R/screen_queen.R b/beekeeper/R/screen_queen.R new file mode 100644 index 0000000..66ae2fe --- /dev/null +++ b/beekeeper/R/screen_queen.R @@ -0,0 +1,102 @@ +screen_queen_ui <- function(id) { + ns <- NS(id) + shiny::div( + shiny::uiOutput(ns("current_queen")), + section_card("Neue Königin erfassen", + shiny::div(class = "row g-2 py-2", + shiny::div(class = "col-6", + shiny::numericInput(ns("year"), "Jahrgang", + value = as.integer(format(Sys.Date(), "%Y")), min = 2000, max = 2100, step = 1) + ), + shiny::div(class = "col-6", + shiny::textInput(ns("breed"), "Rasse / Zucht") + ) + ), + btn_group_row(ns, "color", "Markierungsfarbe", + c("Weiß" = "weiss", "Gelb" = "gelb", "Rot" = "rot", "Grün" = "gruen", "Blau" = "blau") + ), + shiny::div(class = "py-2", + shiny::uiOutput(ns("origin_sel")), + shiny::dateInput(ns("introduced"), "Eingeweiselt am", + value = Sys.Date(), language = "de", weekstart = 1), + shiny::textAreaInput(ns("notes"), "Notizen", rows = 2, width = "100%") + ) + ), + shiny::div(class = "d-grid mt-2 mb-5", + shiny::actionButton(ns("save"), "Königin speichern", class = "btn btn-info btn-lg") + ) + ) +} + +screen_queen_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + color_v <- btn_group_server("color", ns, input, output, + c("Weiß" = "weiss", "Gelb" = "gelb", "Rot" = "rot", "Grün" = "gruen", "Blau" = "blau")) + + output$current_queen <- renderUI({ + req(nav$colony_id) + co <- db_colonies_get(db, nav$colony_id) + req(nrow(co) > 0, !is.na(co$current_queen_id)) + q <- db_queens_get(db, co$current_queen_id) + req(nrow(q) > 0) + bslib::card( + class = "mb-3", + bslib::card_header("Aktuelle Königin"), + bslib::card_body( + shiny::div(class = "row g-2", + shiny::div(class = "col-4", + shiny::tags$small(class = "text-muted", "Jahrgang"), shiny::tags$br(), + shiny::tags$strong(q$year %||% "–") + ), + shiny::div(class = "col-4", + shiny::tags$small(class = "text-muted", "Rasse"), shiny::tags$br(), + shiny::tags$strong(q$breed %||% "–") + ), + shiny::div(class = "col-4", + shiny::tags$small(class = "text-muted", "Farbe"), shiny::tags$br(), + shiny::tags$strong(q$marked_color %||% "–") + ) + ), + shiny::div(class = "mt-2", + shiny::actionButton(ns("supersede"), "Ablösen (neue Königin)", + class = "btn btn-sm btn-outline-warning") + ) + ) + ) + }) + + observeEvent(input$supersede, { + req(nav$colony_id) + co <- db_colonies_get(db, nav$colony_id) + if (!is.na(co$current_queen_id)) db_queens_supersede(db, co$current_queen_id) + }) + + output$origin_sel <- renderUI({ + cols <- db_colonies_list(db) + shiny::selectInput(ns("origin"), "Herkunft (Muttervolk)", + choices = c("Unbekannt" = "", setNames(cols$id, cols$name)) + ) + }) + + observeEvent(nav$screen, { + if (nav$screen == "queens") color_v(NA) + }, ignoreInit = TRUE) + + observeEvent(input$save, { + req(nav$colony_id) + origin <- if (!is.null(input$origin) && input$origin != "") + as.integer(input$origin) else NA + db_queens_insert(db, + colony_id = nav$colony_id, + year = input$year, + breed = input$breed, + origin_colony_id = origin, + marked_color = color_v(), + notes = input$notes, + introduced_at = as.character(input$introduced) + ) + nav$screen <- "colony" + }) + }) +} diff --git a/beekeeper/R/screen_quick_insp.R b/beekeeper/R/screen_quick_insp.R new file mode 100644 index 0000000..1172abb --- /dev/null +++ b/beekeeper/R/screen_quick_insp.R @@ -0,0 +1,113 @@ +screen_quick_insp_ui <- function(id) { + ns <- NS(id) + shiny::tagList( + section_card(NULL, + shiny::div(class = "row g-2 py-2", + shiny::div(class = "col-6", + shiny::dateInput(ns("date"), "Datum", value = Sys.Date(), language = "de", weekstart = 1) + ), + shiny::div(class = "col-6", + shiny::textInput(ns("inspector"), "Imker/in") + ) + ) + ), + shiny::uiOutput(ns("colony_cards")), + shiny::div(class = "d-grid mt-2 mb-5", + shiny::actionButton(ns("save_all"), "Alle speichern", + class = "btn btn-primary btn-lg") + ) + ) +} + +screen_quick_insp_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + + colonies <- reactive({ + req(nav$apiary_id, nav$screen == "quick_inspection") + db_colonies_list(db, apiary_id = nav$apiary_id, include_closed = FALSE) + }) + + output$colony_cards <- renderUI({ + cols <- colonies() + if (nrow(cols) == 0) + return(shiny::p(class = "text-muted text-center py-4", "Keine aktiven Völker.")) + + lapply(seq_len(nrow(cols)), function(i) { + row <- cols[i, ] + cid <- row$id + section_card( + shiny::div(class = "d-flex justify-content-between align-items-center", + shiny::tags$strong(row$name), + shiny::HTML(colony_status_badge(row$status)) + ), + # Königin / Eier toggles + shiny::div(class = "d-flex gap-4 py-2 border-bottom", + toggle_row_inline(ns(paste0("qk_", cid)), "K gesehen"), + toggle_row_inline(ns(paste0("qe_", cid)), "Eier") + ), + # Brutwaben + shiny::div( + class = "d-flex justify-content-between align-items-center py-2 border-bottom", + shiny::tags$span("Brutwaben", class = "text-muted small"), + shiny::numericInput(ns(paste0("qb_", cid)), NULL, + value = NA, min = 0, max = 20, step = 1, width = "80px") + ), + # Notizen + shiny::div(class = "py-2", + shiny::textAreaInput(ns(paste0("qn_", cid)), NULL, + placeholder = "Notizen...", rows = 2, width = "100%") + ) + ) + }) + }) + + observeEvent(input$save_all, { + cols <- isolate(colonies()) + if (nrow(cols) == 0) return() + + n_saved <- 0L + for (i in seq_len(nrow(cols))) { + cid <- cols$id[i] + queen <- isTRUE(input[[paste0("qk_", cid)]]) + eggs <- isTRUE(input[[paste0("qe_", cid)]]) + brood <- suppressWarnings(as.integer(input[[paste0("qb_", cid)]])) + notes <- trimws(input[[paste0("qn_", cid)]] %||% "") + + if (!queen && !eggs && is.na(brood) && nchar(notes) == 0) next + + tryCatch({ + db_inspections_insert(db, + colony_id = cid, + date = input$date, + inspector = input$inspector, + brood_frames = if (!is.na(brood)) brood else NA_integer_, + queen_seen = queen, + eggs_seen = eggs, + notes = if (nchar(notes) > 0) notes else NA_character_, + traits = list() + ) + n_saved <- n_saved + 1L + log_debug("quick_insp_saved", colony_id = cid) + }, error = function(e) { + log_error("quick_insp_error", colony_id = cid, msg = conditionMessage(e)) + }) + } + + if (n_saved > 0) + shiny::showNotification( + sprintf("%d Durchsicht(en) gespeichert.", n_saved), + type = "message", duration = 3 + ) + nav$screen <- "colonies" + }) + }) +} + +# Compact inline toggle (no label on separate line) +toggle_row_inline <- function(input_id, label) { + shiny::div(class = "d-flex align-items-center gap-2", + shiny::checkboxInput(input_id, label = NULL, value = FALSE), + shiny::tags$label(`for` = input_id, class = "mb-0", label) + ) +} diff --git a/beekeeper/R/screen_varroa.R b/beekeeper/R/screen_varroa.R new file mode 100644 index 0000000..04e2ba5 --- /dev/null +++ b/beekeeper/R/screen_varroa.R @@ -0,0 +1,181 @@ +VARROA_METHODS_DISPLAY <- c( + "Nat. Totenfall" = "natuerlicher_totenfall", + "Alkoholwäsche" = "alkoholwaesche", + "Puderzucker" = "puderzucker" +) + +screen_varroa_ui <- function(id) { + ns <- NS(id) + bslib::navset_card_tab( + bslib::nav_panel("Kontrolle", + shiny::div(class = "p-2", + section_card(NULL, + shiny::div(class = "row g-2 py-2", + shiny::div(class = "col-4", + shiny::dateInput(ns("count_date_from"), "Von", value = Sys.Date() - 3L, language = "de", weekstart = 1) + ), + shiny::div(class = "col-4", + shiny::dateInput(ns("count_date_to"), "Bis", value = Sys.Date(), language = "de", weekstart = 1) + ), + shiny::div(class = "col-4", + shiny::textInput(ns("count_time"), "Uhrzeit", + value = format(Sys.time(), "%H:%M"), placeholder = "HH:MM") + ) + ) + ), + btn_group_row(ns, "count_method", "Methode", VARROA_METHODS_DISPLAY), + stepper_row(ns, "count_val", "Anzahl / Wert"), + shiny::uiOutput(ns("milben_pro_tag_ui")), + section_card(NULL, + shiny::div(class = "py-2", + shiny::div( + class = "bk-check d-flex justify-content-between align-items-center py-2 border-bottom", + shiny::tags$label(class = "fw-semibold", `for` = ns("is_ctrl"), "Nachkontrolle zu Behandlung?"), + shiny::checkboxInput(ns("is_ctrl"), NULL, value = FALSE) + ), + shiny::uiOutput(ns("ctrl_extra_ui")), + shiny::textAreaInput(ns("count_notes"), "Notizen", rows = 2, width = "100%") + ) + ), + shiny::div(class = "d-grid mt-2", + shiny::actionButton(ns("save_count"), "Kontrolle speichern", class = "btn btn-primary btn-lg") + ) + ) + ), + bslib::nav_panel("Behandlung", + shiny::div(class = "p-2", + section_card(NULL, + shiny::div(class = "row g-2 py-2", + shiny::div(class = "col-6", + shiny::dateInput(ns("treat_date"), "Datum", value = Sys.Date(), language = "de", weekstart = 1) + ), + shiny::div(class = "col-6", + shiny::textInput(ns("treat_time"), "Uhrzeit", + value = format(Sys.time(), "%H:%M"), placeholder = "HH:MM") + ), + shiny::div(class = "col-12", + shiny::selectizeInput(ns("treat_product"), "Mittel *", + choices = c("Mittel wählen…" = "", VARROA_PRODUCTS), + options = list(create = TRUE, placeholder = "Mittel wählen…") + ) + ) + ) + ), + section_card(NULL, + shiny::div(class = "py-2", + shiny::uiOutput(ns("treat_method_ui")), + shiny::textInput(ns("treat_dosage"), "Dosierung", placeholder = "2,3 g / Volk"), + shiny::textAreaInput(ns("treat_notes"), "Notizen", rows = 2, width = "100%") + ) + ), + shiny::div(class = "d-grid mt-2", + shiny::actionButton(ns("save_treat"), "Behandlung speichern", class = "btn btn-warning btn-lg") + ) + ) + ) + ) +} + +screen_varroa_server <- function(id, db, nav) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + + count_method <- btn_group_server("count_method", ns, input, output, + VARROA_METHODS_DISPLAY, default = "natuerlicher_totenfall") + count_val <- stepper_server("count_val", input, output) + + observeEvent(nav$screen, { + if (nav$screen == "varroa") { + count_val(NA_integer_) + shiny::updateCheckboxInput(session, "is_ctrl", value = FALSE) + shiny::updateDateInput(session, "count_date_from", value = Sys.Date() - 3L) + shiny::updateDateInput(session, "count_date_to", value = Sys.Date()) + shiny::updateTextInput(session, "count_time", value = format(Sys.time(), "%H:%M")) + shiny::updateTextInput(session, "treat_time", value = format(Sys.time(), "%H:%M")) + } + }, ignoreInit = TRUE) + + output$milben_pro_tag_ui <- renderUI({ + v <- count_val() + d_von <- input$count_date_from + d_bis <- input$count_date_to + if (is.na(v) || is.null(d_von) || is.null(d_bis)) return(NULL) + days <- as.numeric(as.Date(d_bis) - as.Date(d_von)) + if (days < 0) return(shiny::p(class = "text-danger small py-1", "Bis-Datum liegt vor Von-Datum.")) + mpt <- round(v / max(1, days), 1) + cls <- if (mpt < 1) "text-success" else if (mpt < 3) "text-warning" else "text-danger" + shiny::div( + class = paste("text-center py-2 fw-bold fs-4", cls), + paste0(mpt, " Milben/Tag"), + shiny::tags$div(class = "text-muted fw-normal small", + paste0(v, " Milben über ", days, " Tage")) + ) + }) + + output$ctrl_extra_ui <- renderUI({ + req(isTRUE(input$is_ctrl), nav$colony_id) + treats <- db_varroa_treatments_list(db, nav$colony_id) + if (nrow(treats) == 0) + return(shiny::p(class = "text-muted small py-2", "Keine Behandlungen erfasst.")) + shiny::tagList( + shiny::selectInput(ns("ctrl_treatment"), "Zur Behandlung", + choices = setNames(treats$id, paste(treats$date, treats$product)) + ), + shiny::textInput(ns("ctrl_result"), "Ergebnis / Befund") + ) + }) + + output$treat_method_ui <- renderUI({ + product <- input$treat_product + methods <- if (!is.null(product) && product != "") VARROA_APPLICATION_METHODS[[product]] else NULL + if (!is.null(methods)) { + shiny::selectizeInput(ns("treat_method"), "Anwendung", + choices = c("Anwendung wählen…" = "", methods), + options = list(create = TRUE, placeholder = "Anwendung wählen…") + ) + } else { + shiny::textInput(ns("treat_method"), "Anwendung", placeholder = "z. B. Träufeln") + } + }) + + observeEvent(input$save_count, { + req(nav$colony_id, !is.na(count_val())) + db_varroa_counts_insert(db, + colony_id = nav$colony_id, + date_from = input$count_date_from, + date = input$count_date_to, + method = count_method() %||% "natuerlicher_totenfall", + count = count_val(), + notes = input$count_notes, + time = input$count_time %||% NA + ) + if (isTRUE(input$is_ctrl) && !is.null(input$ctrl_treatment)) { + db_varroa_controls_insert(db, + treatment_id = as.integer(input$ctrl_treatment), + colony_id = nav$colony_id, + date = input$count_date, + result = input$ctrl_result %||% "", + notes = input$count_notes + ) + } + count_val(NA_integer_) + shiny::updateTextAreaInput(session, "count_notes", value = "") + shiny::updateCheckboxInput(session, "is_ctrl", value = FALSE) + nav$screen <- "colony" + }) + + observeEvent(input$save_treat, { + req(nav$colony_id, nchar(trimws(input$treat_product)) > 0) + db_varroa_treatments_insert(db, + colony_id = nav$colony_id, + date = input$treat_date, + product = trimws(input$treat_product), + method = input$treat_method, + dosage = input$treat_dosage, + notes = input$treat_notes, + time = input$treat_time %||% NA + ) + nav$screen <- "colony" + }) + }) +} diff --git a/beekeeper/R/server.R b/beekeeper/R/server.R new file mode 100644 index 0000000..fa2c4d3 --- /dev/null +++ b/beekeeper/R/server.R @@ -0,0 +1,101 @@ +server <- function(input, output, session, db) { + + nav <- reactiveValues( + screen = "apiaries", + apiary_id = NULL, + colony_id = NULL + ) + + # ---- Module initialization ------------------------------------------------ + screen_apiaries_server("s_apiaries", db = db, nav = nav) + screen_colonies_server("s_colonies", db = db, nav = nav) + screen_colony_server("s_colony", db = db, nav = nav) + screen_inspection_server("s_insp", db = db, nav = nav) + screen_varroa_server("s_varroa", db = db, nav = nav) + screen_harvest_server("s_harvest", db = db, nav = nav) + screen_queen_server("s_queen", db = db, nav = nav) + screen_checklists_server("s_chklst", db = db, nav = nav) + screen_actions_server("s_actions", db = db, nav = nav) + screen_lineage_server("s_lineage", db = db, nav = nav) + screen_quick_insp_server("s_qinsp", db = db, nav = nav) + screen_bulk_action_server("s_bulk", db = db, nav = nav) + + # ---- Navigation header --------------------------------------------------- + BACK <- c( + colonies = "apiaries", + colony = "colonies", + inspection = "colony", + varroa = "colony", + harvest = "colony", + queens = "colony", + checklists = "colony", + actions = "colony", + lineage = "colonies", + quick_inspection = "colonies", + bulk_action = "colonies" + ) + + output$nav_header <- renderUI({ + screen <- nav$screen + if (screen == "apiaries") { + return(shiny::div( + class = "bk-header d-flex justify-content-between align-items-center", + shiny::tags$h4("Beekeeper"), + shiny::actionButton("btn_settings", shiny::icon("gear"), + class = "btn btn-link bk-header-icon", title = "Einstellungen") + )) + } + title <- switch(screen, + colonies = { + ap <- db_apiaries_get(db, nav$apiary_id) + if (nrow(ap) > 0) ap$name[1] else "Stand" + }, + colony = { + co <- db_colonies_get(db, nav$colony_id) + if (nrow(co) > 0) co$name[1] else "Volk" + }, + inspection = "Neue Durchsicht", + quick_inspection = "Schnelldurchsicht", + bulk_action = "Sammelmaßnahme", + varroa = "Varroa", + harvest = "Honigernte", + queens = "Königin", + checklists = "Checkliste", + actions = "Maßnahmen", + lineage = "Stammbaum", + "" + ) + shiny::div( + class = "bk-header d-flex align-items-center gap-2", + shiny::actionButton("nav_back", shiny::icon("arrow-left"), + class = "btn btn-link bk-header-icon" + ), + shiny::tags$h5(class = "mb-0 flex-grow-1", title) + ) + }) + + observeEvent(input$nav_back, { + target <- BACK[nav$screen] + if (!is.na(target)) nav$screen <- target + }) + + observeEvent(input$keepalive_ping, {}, ignoreNULL = TRUE, ignoreInit = TRUE) + + # ---- Content switcher ---------------------------------------------------- + output$screen_content <- renderUI({ + switch(nav$screen, + apiaries = screen_apiaries_ui("s_apiaries"), + colonies = screen_colonies_ui("s_colonies"), + colony = screen_colony_ui("s_colony"), + inspection = screen_inspection_ui("s_insp"), + varroa = screen_varroa_ui("s_varroa"), + harvest = screen_harvest_ui("s_harvest"), + queens = screen_queen_ui("s_queen"), + checklists = screen_checklists_ui("s_chklst"), + actions = screen_actions_ui("s_actions"), + lineage = screen_lineage_ui("s_lineage"), + quick_inspection = screen_quick_insp_ui("s_qinsp"), + bulk_action = screen_bulk_action_ui("s_bulk") + ) + }) +} diff --git a/beekeeper/R/ui.R b/beekeeper/R/ui.R new file mode 100644 index 0000000..c30555c --- /dev/null +++ b/beekeeper/R/ui.R @@ -0,0 +1,83 @@ +ui <- function() { + bslib::page( + fillable = FALSE, + theme = bslib::bs_theme(version = 5, primary = "#f59e0b"), + lang = "de", + title = "Beekeeper", + + shiny::tags$style(shiny::HTML(" + body { background: #fafaf9; } + .bk-header { + background: #78350f; color: white; + position: sticky; top: 0; z-index: 100; + padding: .75rem 1rem; + box-shadow: 0 2px 8px rgba(0,0,0,.3); + min-height: 56px; + } + .bk-header h5 { color: white; margin: 0; font-size: 1.05rem; font-weight: 600; } + .bk-header h4 { color: white; margin: 0; font-size: 1.2rem; font-weight: 700; letter-spacing: -.01em; } + .bk-header .btn-link { + color: rgba(255,255,255,.9) !important; text-decoration: none; + } + .bk-header-icon { + padding: .35rem .45rem; border-radius: .4rem; line-height: 1; flex-shrink: 0; + } + .bk-header-icon:hover { background: rgba(255,255,255,.18) !important; color: white !important; } + .collapse-toggle { cursor: pointer; user-select: none; } + .collapse-toggle .collapse-chevron { transition: transform .2s ease; } + .collapse-toggle:not(.collapsed) .collapse-chevron { transform: rotate(180deg); } + .bk-content { max-width: 680px; margin: 0 auto; padding: 1rem 1rem 4rem; } + .tap-card { cursor: pointer; transition: box-shadow .15s; } + .tap-card:hover { box-shadow: 0 0 0 3px #f59e0b44; } + .tap-card:active { box-shadow: 0 0 0 3px #f59e0b; } + .action-btn { + display: flex; flex-direction: column; + align-items: center; justify-content: center; + height: 80px; gap: .35rem; + font-size: .82rem; font-weight: 600; + border-radius: 12px; + } + .action-btn .fa, .action-btn .fas, .action-btn .far { font-size: 1.4rem; } + .stepper-val { + min-width: 2.8rem; display: inline-block; + text-align: center; font-size: 1.3rem; font-weight: 700; + } + .bk-check .form-check { margin: 0; } + .bk-check .form-check-input { + width: 3em; height: 1.5em; margin-top: 0; cursor: pointer; + } + .add-dashed { + border: 2px dashed #ccc; background: transparent; + border-radius: 8px; cursor: pointer; width: 100%; + min-height: 100px; color: #888; + display: flex; align-items: center; justify-content: center; + gap: .5rem; flex-direction: column; font-weight: 600; + } + .add-dashed:hover { border-color: #f59e0b; color: #f59e0b; background: #fffbeb; } + .leaflet-container { border-radius: 8px; } + .leaflet-top, .leaflet-bottom { z-index: 50 !important; } + #ss-connect-dialog, .shiny-disconnected-overlay { display: none !important; } + ")), + + shiny::tags$script(shiny::HTML(" + $(document).on('shown.bs.modal', function() { + setTimeout(function() { window.dispatchEvent(new Event('resize')); }, 150); + }); + + history.pushState({bk: true}, ''); + window.addEventListener('popstate', function() { + history.pushState({bk: true}, ''); + if (window.Shiny) Shiny.setInputValue('nav_back', Date.now(), {priority: 'event'}); + }); + + setInterval(function() { + if (window.Shiny && Shiny.shinyapp && Shiny.shinyapp.isConnected()) + Shiny.setInputValue('keepalive_ping', Date.now()); + }, 25000); + ")), + shiny::uiOutput("nav_header"), + shiny::div(class = "bk-content", + shiny::uiOutput("screen_content") + ) + ) +} diff --git a/beekeeper/R/utils.R b/beekeeper/R/utils.R new file mode 100644 index 0000000..d242e79 --- /dev/null +++ b/beekeeper/R/utils.R @@ -0,0 +1,101 @@ +`%||%` <- function(a, b) if (!is.null(a) && length(a) > 0 && !is.na(a[1]) && a[1] != "") a else b + +colony_status_label <- function(status) { + labels <- c( + wirtschaftsvolk = "Wirtschaftsvolk", + ableger = "Ableger", + jungvolk = "Jungvolk", + schwarmstimmung = "Schwarmstimmung", + eingegangen = "Eingegangen" + ) + labels[status] %||% status +} + +colony_status_color <- function(status) { + colors <- c( + wirtschaftsvolk = "success", + ableger = "info", + jungvolk = "warning", + schwarmstimmung = "danger", + eingegangen = "secondary" + ) + colors[status] %||% "secondary" +} + +colony_status_badge <- function(status) { + sprintf( + '%s', + colony_status_color(status), + colony_status_label(status) + ) +} + +trait_label <- function(trait) { + labels <- c( + sanftmut = "Sanftmut", + schwarmtrieb = "Schwarmtrieb", + wabensitz = "Wabensitz", + honigertrag = "Honigertrag", + varroa_hygiene = "Varroa-Hygiene" + ) + labels[trait] %||% trait +} + +COLONY_STATUSES <- c( + "Wirtschaftsvolk" = "wirtschaftsvolk", + "Ableger" = "ableger", + "Jungvolk" = "jungvolk", + "Schwarmstimmung" = "schwarmstimmung", + "Eingegangen" = "eingegangen" +) + +TRAITS <- c( + "Sanftmut" = "sanftmut", + "Schwarmtrieb" = "schwarmtrieb", + "Wabensitz" = "wabensitz", + "Honigertrag" = "honigertrag", + "Varroa-Hygiene" = "varroa_hygiene" +) + +VARROA_PRODUCTS <- c("Ameisensäure", "Oxalsäure", "Thymol", "Apibioxal", "MAQS") + +VARROA_APPLICATION_METHODS <- list( + "Ameisensäure" = c("Kurzzeitbehandlung (4h)", "Langzeitbehandlung (3 Wo.)", "Schwammtuch"), + "Oxalsäure" = c("Träufeln", "Sublimation / Verdampfen", "Sprühen"), + "Thymol" = c("Apilife VAR", "Thymovar", "Thymol-Kristalle"), + "Apibioxal" = c("Träufeln", "Sublimation / Verdampfen"), + "MAQS" = c("Streifen-Einlage") +) + +VARROA_METHODS <- c( + "Natürlicher Totenfall" = "natuerlicher_totenfall", + "Alkoholwäsche" = "alkoholwaesche", + "Puderzucker" = "puderzucker" +) + +HONEY_TYPES <- c( + "Frühtracht" = "fruehtracht", + "Sommertracht" = "sommertracht", + "Waldhonig" = "waldhonig", + "Rapshonig" = "rapshonig", + "Akazienhonig" = "akazienhonig", + "Sonstiges" = "sonstiges" +) + +COLONY_ACTION_TYPES <- c( + "Ablegerbildung" = "ablegerbildung", + "Zwischenbödenableger" = "zwischenbodenableger", + "Brutentnahme" = "brutentnahme", + "Standwechsel" = "standwechsel", + "Fütterung" = "futterung", + "Völkervereinigung" = "vereinigung", + "Sonstige Maßnahme" = "sonstiges" +) + +QUEEN_COLORS <- c( + "Weiß (2021, 2026)" = "weiss", + "Gelb (2022, 2027)" = "gelb", + "Rot (2023, 2028)" = "rot", + "Grün (2024, 2029)" = "gruen", + "Blau (2025, 2030)" = "blau" +) diff --git a/beekeeper/R/widgets.R b/beekeeper/R/widgets.R new file mode 100644 index 0000000..0d9fd52 --- /dev/null +++ b/beekeeper/R/widgets.R @@ -0,0 +1,156 @@ +# ---- Stepper ---------------------------------------------------------------- + +stepper_row <- function(ns, id, label) { + shiny::div( + class = "d-flex justify-content-between align-items-center py-3 border-bottom", + shiny::span(label, class = "fw-semibold"), + shiny::div( + class = "d-flex align-items-center gap-3", + shiny::actionButton(ns(paste0(id, "_minus")), "−", + class = "btn btn-outline-secondary rounded-circle p-0", + style = "width:44px;height:44px;font-size:1.3rem;line-height:1" + ), + shiny::uiOutput(ns(paste0(id, "_disp")), inline = TRUE), + shiny::actionButton(ns(paste0(id, "_plus")), "+", + class = "btn btn-outline-secondary rounded-circle p-0", + style = "width:44px;height:44px;font-size:1.3rem;line-height:1" + ) + ) + ) +} + +stepper_server <- function(id, input, output, default = NA_integer_) { + val <- reactiveVal(default) + observeEvent(input[[paste0(id, "_plus")]], { + v <- val(); val(if (is.na(v)) 1L else v + 1L) + }, ignoreInit = TRUE) + observeEvent(input[[paste0(id, "_minus")]], { + v <- val(); if (!is.na(v) && v > 0) val(v - 1L) + }, ignoreInit = TRUE) + output[[paste0(id, "_disp")]] <- renderUI({ + v <- val() + shiny::tags$span(class = "stepper-val", + if (is.na(v)) "–" else as.character(v)) + }) + val +} + +# ---- Info popover ----------------------------------------------------------- + +info_icon <- function(title, content) { + bslib::popover( + trigger = shiny::icon("circle-info", + style = paste( + "cursor:pointer;color:#6c757d;font-size:.8em;", + "margin-left:.35rem;vertical-align:middle" + ) + ), + title = title, + content, + placement = "auto" + ) +} + +# ---- Button-group selector (trait 1-4, strength 1-9, methods) -------------- + +btn_group_row <- function(ns, id, label, choices, info = NULL, stacked = FALSE) { + label_el <- shiny::tagList(label, if (!is.null(info)) info_icon(info$title, info$content)) + if (stacked) { + shiny::div( + class = "py-2 border-bottom", + shiny::div(class = "fw-semibold mb-2", label_el), + shiny::uiOutput(ns(paste0(id, "_btns"))) + ) + } else { + shiny::div( + class = "d-flex justify-content-between align-items-center py-2 border-bottom", + shiny::span(class = "fw-semibold", label_el), + shiny::uiOutput(ns(paste0(id, "_btns")), inline = TRUE) + ) + } +} + +btn_group_server <- function(id, ns, input, output, choices, default = NA) { + val <- reactiveVal(default) + lapply(seq_along(choices), function(i) { + observeEvent(input[[paste0(id, "_", i)]], { + if (identical(val(), choices[[i]])) val(NA) else val(choices[[i]]) + }, ignoreInit = TRUE) + }) + output[[paste0(id, "_btns")]] <- renderUI({ + v <- val() + shiny::div( + class = "d-flex flex-wrap gap-1", + lapply(seq_along(choices), function(i) { + selected <- !is.na(v) && identical(v, choices[[i]]) + cls <- if (selected) "btn btn-sm btn-primary" else "btn btn-sm btn-outline-secondary" + shiny::actionButton(ns(paste0(id, "_", i)), names(choices)[i], + class = cls, style = "min-width:38px;min-height:38px") + }) + ) + }) + val +} + +trait_row <- function(ns, id, label, info = NULL) btn_group_row(ns, id, label, stats::setNames(1:4, 1:4), info = info) +trait_server <- function(id, ns, input, output) btn_group_server(id, ns, input, output, stats::setNames(1:4, 1:4)) + +STRENGTH_INFO <- list( + title = "Volksstärke (1–9)", + content = shiny::tagList( + shiny::tags$p("Schätzung der Bienenmenge nach Liebefeld-Methode:"), + shiny::tags$table(class = "table table-sm table-borderless mb-0", + shiny::tags$tbody( + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("1–2"), .noWS = "after"), shiny::tags$td("sehr schwaches Volk")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("3–4"), .noWS = "after"), shiny::tags$td("schwaches bis mittleres Volk")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("5–6"), .noWS = "after"), shiny::tags$td("mittleres bis starkes Volk")), + shiny::tags$tr(shiny::tags$td(shiny::tags$strong("7–9"), .noWS = "after"), shiny::tags$td("sehr starkes Volk")) + ) + ), + shiny::tags$p(class = "mt-2 mb-0 text-muted small", "Winterüberwinterung: mind. 4–5 anstreben.") + ) +) + +strength_row <- function(ns, id = "strength") btn_group_row(ns, id, "Volksstärke", stats::setNames(1:9, 1:9), info = STRENGTH_INFO, stacked = TRUE) +strength_server <- function(id = "strength", ns, input, output) btn_group_server(id, ns, input, output, stats::setNames(1:9, 1:9)) + +# ---- Toggle (checkbox styled as switch) ------------------------------------ + +toggle_row <- function(ns, id, label) { + shiny::div( + class = "bk-check d-flex justify-content-between align-items-center py-3 border-bottom", + shiny::tags$label(class = "fw-semibold", `for` = ns(id), label), + shiny::checkboxInput(ns(id), NULL, value = FALSE) + ) +} + +# ---- Section card ----------------------------------------------------------- + +section_card <- function(header = NULL, ...) { + bslib::card( + class = "mb-3", + if (!is.null(header)) bslib::card_header(header), + bslib::card_body(class = "px-2 py-0", ...) + ) +} + +# ---- Collapsible card (Bootstrap collapse) ---------------------------------- + +collapsible_card <- function(header, ..., collapse_id, open = FALSE) { + shiny::tags$div(class = "card mb-3", + shiny::tags$div( + class = paste("card-header d-flex justify-content-between align-items-center collapse-toggle", + if (!open) "collapsed"), + `data-bs-toggle` = "collapse", + `data-bs-target` = paste0("#", collapse_id), + `aria-expanded` = tolower(as.character(open)), + shiny::tags$span(class = "fw-semibold small text-uppercase text-muted", header), + shiny::tags$i(class = "fa fa-chevron-down collapse-chevron text-muted ms-2") + ), + shiny::tags$div( + id = collapse_id, + class = if (open) "collapse show" else "collapse", + shiny::tags$div(class = "card-body px-2 py-0", ...) + ) + ) +} diff --git a/beekeeper/app.R b/beekeeper/app.R new file mode 100644 index 0000000..afbb030 --- /dev/null +++ b/beekeeper/app.R @@ -0,0 +1,2 @@ +pkgload::load_all() +beekeeper::run_app() diff --git a/beekeeper/shiny.Rproj b/beekeeper/shiny.Rproj new file mode 100644 index 0000000..21a4da0 --- /dev/null +++ b/beekeeper/shiny.Rproj @@ -0,0 +1,17 @@ +Version: 1.0 + +RestoreWorkspace: Default +SaveWorkspace: Default +AlwaysSaveHistory: Default + +EnableCodeIndexing: Yes +UseSpacesForTab: Yes +NumSpacesForTab: 2 +Encoding: UTF-8 + +RnwWeave: Sweave +LaTeX: pdfLaTeX + +BuildType: Package +PackageUseDevtools: Yes +PackageInstallArgs: --no-multiarch --with-keep.source diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8915f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +name: homekeeper + +services: + db: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_DB: homestead + POSTGRES_USER: homestead + POSTGRES_PASSWORD: homestead + volumes: + - ./pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U homestead -d homestead"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: ./api + depends_on: + db: + condition: service_healthy + environment: + DB_HOST: db + DB_PORT: 5432 + DB_NAME: homestead + DB_USER: homestead + DB_PASSWORD: homestead + ROOT_PATH: /api + INITIAL_USERS: "homestead:homestead" + restart: unless-stopped + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./www:/usr/share/nginx/html:ro + depends_on: + - beekeeper + - api + restart: unless-stopped + + listkeeper: + build: ./listkeeper + depends_on: + api: + condition: service_started + environment: + API_URL: http://api:8000 + API_USER: homestead + API_PASS: homestead + LOG_DIR: /logs + volumes: + - ./listkeeper/logs:/logs + restart: unless-stopped + + beekeeper: + build: ./beekeeper + depends_on: + api: + condition: service_started + environment: + API_URL: http://api:8000 + API_USER: homestead + API_PASS: homestead + LOG_DIR: /logs + volumes: + - ./beekeeper/logs:/logs + restart: unless-stopped + diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 0000000..98ba006 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,90 @@ +# Homekeeper Infrastructure + +Ansible setup for a Hetzner VM (Ubuntu 24.04) running Homekeeper via Podman. + +## Architecture + +``` +nginx (system) ← HTTPS, routes by path +├── / ← static landing page (/var/www/html) +├── /gitea/ → Gitea on :3000 (git + container registry) +├── /api/ → homekeeper-api on :8000 +├── /beekeeper/ → homekeeper-beekeeper on :3838 +└── /listkeeper/ → homekeeper-listkeeper on :3839 + +Podman (rootful, Quadlets → systemd units) +├── homekeeper-db postgres:17, data at /opt/homekeeper/pg_data +├── homekeeper-api from Gitea registry, auto-update enabled +├── homekeeper-beekeeper from Gitea registry, auto-update enabled +└── homekeeper-listkeeper from Gitea registry, auto-update enabled + +gitea container from docker.io, auto-update disabled +``` + +## First-time setup + +### 1. Provision a Hetzner VM +- Ubuntu 24.04, min. CX22 (2 vCPU, 4 GB RAM) +- Add your SSH key in Hetzner console +- Point your domain DNS A-record to the VM IP + +### 2. Configure variables +Edit `group_vars/all.yml`: +- Set `domain` +- Set all `CHANGE_ME` passwords (consider `ansible-vault encrypt_string`) +- Update `inventory/hosts.yml` with the VM IP + +### 3. First run (without registry login — Gitea not yet up) +Comment out the "Login to Gitea registry" task in `roles/podman/tasks/main.yml`, +then run without the homekeeper role: + +```bash +ansible-playbook -i inventory/hosts.yml site.yml --skip-tags homekeeper +``` + +### 4. Set up Gitea +- Visit https://git.friessn.de/ and complete the installation wizard +- Create user matching `gitea_admin_user` +- Create a repository for the code +- Generate an API token (Settings → Applications) with `package:write` scope +- Set `registry_token` in `group_vars/all.yml` + +### 5. Push images to Gitea registry +On your local machine, build and push the three custom images: + +```bash +# Login to Gitea registry +docker login git.friessn.de -u nico + +# Build and push +docker build -t git.friessn.de/nico/homekeeper-api:latest ./api +docker build -t git.friessn.de/nico/homekeeper-beekeeper:latest ./beekeeper +docker build -t git.friessn.de/nico/homekeeper-listkeeper:latest ./listkeeper + +docker push git.friessn.de/nico/homekeeper-api:latest +docker push git.friessn.de/nico/homekeeper-beekeeper:latest +docker push git.friessn.de/nico/homekeeper-listkeeper:latest +``` + +### 6. Full playbook run +```bash +ansible-playbook -i inventory/hosts.yml site.yml +``` + +## Deploy updates + +After pushing a new image to the Gitea registry, `podman auto-update` picks it up +automatically within `autoupdate_schedule` (default: every 15 minutes). + +For an immediate deploy: +```bash +ssh root@YOUR_IP podman auto-update +``` + +## Secrets management + +For production, use ansible-vault: +```bash +ansible-vault encrypt_string 'mysecretpassword' --name 'db_password' +``` +Paste the output into `group_vars/all.yml` and run playbooks with `--ask-vault-pass`. diff --git a/infrastructure/group_vars/all.yml b/infrastructure/group_vars/all.yml new file mode 100644 index 0000000..1ea173a --- /dev/null +++ b/infrastructure/group_vars/all.yml @@ -0,0 +1,29 @@ +--- +# Main domain (apps + landing page) +domain: home.friessn.de + +# Gitea on a subdomain — cleaner than a subpath, required for container registry +gitea_domain: git.friessn.de +gitea_data_dir: /opt/gitea +gitea_http_port: 3000 # internal port, nginx proxies HTTPS → here +gitea_admin_user: nico +gitea_admin_password: CHANGE_ME # replace — set via ansible-vault in production +gitea_admin_email: nico.friess@googlemail.com + +# Container registry — Gitea's built-in OCI registry, same host as Gitea +# Images: git.friessn.de/nico/homekeeper-api:latest +registry_host: "{{ gitea_domain }}" +registry_user: "{{ gitea_admin_user }}" +registry_token: CHANGE_ME # Gitea API token with package:write — set after first Gitea start + +# Homekeeper app +homekeeper_data_dir: /opt/homekeeper +db_name: homestead +db_user: homestead +db_password: CHANGE_ME # replace — set via ansible-vault in production +api_user: homestead +api_pass: CHANGE_ME # replace +initial_users: "nico:CHANGE_ME" # user:password pairs for API auth + +# Auto-update timer interval (OnCalendar syntax) +autoupdate_schedule: "*:*:0/10" # every 10 seconds diff --git a/infrastructure/inventory/hosts.yml b/infrastructure/inventory/hosts.yml new file mode 100644 index 0000000..fde1dd7 --- /dev/null +++ b/infrastructure/inventory/hosts.yml @@ -0,0 +1,7 @@ +--- +all: + hosts: + homekeeper: + ansible_host: YOUR_HETZNER_IP # replace with actual IP + ansible_user: root + # ansible_ssh_private_key_file: ~/.ssh/id_ed25519 diff --git a/infrastructure/roles/common/tasks/main.yml b/infrastructure/roles/common/tasks/main.yml new file mode 100644 index 0000000..0b22581 --- /dev/null +++ b/infrastructure/roles/common/tasks/main.yml @@ -0,0 +1,45 @@ +--- +- name: Upgrade all packages + apt: + update_cache: true + upgrade: dist + cache_valid_time: 3600 + +- name: Install base packages + apt: + name: + - curl + - git + - ufw + - fail2ban + - unattended-upgrades + - ca-certificates + state: present + +- name: Configure ufw — allow SSH + ufw: + rule: allow + name: OpenSSH + +- name: Configure ufw — allow HTTP + ufw: + rule: allow + port: "80" + proto: tcp + +- name: Configure ufw — allow HTTPS + ufw: + rule: allow + port: "443" + proto: tcp + +- name: Enable ufw + ufw: + state: enabled + policy: deny + +- name: Enable unattended-upgrades + service: + name: unattended-upgrades + enabled: true + state: started diff --git a/infrastructure/roles/gitea/tasks/main.yml b/infrastructure/roles/gitea/tasks/main.yml new file mode 100644 index 0000000..0f8e1fc --- /dev/null +++ b/infrastructure/roles/gitea/tasks/main.yml @@ -0,0 +1,26 @@ +--- +- name: Create Gitea quadlet directory + file: + path: /etc/containers/systemd + state: directory + mode: "0755" + +- name: Deploy Gitea container quadlet + template: + src: gitea.container.j2 + dest: /etc/containers/systemd/gitea.container + mode: "0644" + notify: + - Reload systemd + - Restart gitea + +handlers: + - name: Reload systemd + systemd: + daemon_reload: true + + - name: Restart gitea + systemd: + name: gitea + state: restarted + enabled: true diff --git a/infrastructure/roles/gitea/templates/gitea.container.j2 b/infrastructure/roles/gitea/templates/gitea.container.j2 new file mode 100644 index 0000000..e8c722c --- /dev/null +++ b/infrastructure/roles/gitea/templates/gitea.container.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=Gitea — Git service and container registry +After=network-online.target + +[Container] +Image=docker.io/gitea/gitea:latest +ContainerName=gitea +PublishPort=127.0.0.1:{{ gitea_http_port }}:3000 +Volume={{ gitea_data_dir }}:/data:Z +Environment=USER_UID=1000 +Environment=USER_GID=1000 +Environment=GITEA__server__DOMAIN={{ gitea_domain }} +Environment=GITEA__server__ROOT_URL=https://{{ gitea_domain }}/ +Environment=GITEA__server__HTTP_PORT=3000 +Environment=GITEA__packages__ENABLED=true +Environment=GITEA__packages__CHUNKED_UPLOAD_PATH=/data/tmp/package-upload + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/infrastructure/roles/homekeeper/tasks/main.yml b/infrastructure/roles/homekeeper/tasks/main.yml new file mode 100644 index 0000000..ef5be0c --- /dev/null +++ b/infrastructure/roles/homekeeper/tasks/main.yml @@ -0,0 +1,43 @@ +--- +- name: Create quadlet directory + file: + path: /etc/containers/systemd + state: directory + mode: "0755" + +- name: Deploy homekeeper network quadlet + template: + src: homekeeper.network.j2 + dest: /etc/containers/systemd/homekeeper.network + mode: "0644" + notify: Reload systemd + +- name: Deploy container quadlets + template: + src: "{{ item }}.j2" + dest: "/etc/containers/systemd/{{ item }}" + mode: "0644" + loop: + - homekeeper-db.container + - homekeeper-api.container + - homekeeper-beekeeper.container + - homekeeper-listkeeper.container + notify: + - Reload systemd + - Restart homekeeper + +handlers: + - name: Reload systemd + systemd: + daemon_reload: true + + - name: Restart homekeeper + systemd: + name: "{{ item }}" + state: restarted + enabled: true + loop: + - homekeeper-db + - homekeeper-api + - homekeeper-beekeeper + - homekeeper-listkeeper diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 new file mode 100644 index 0000000..4035402 --- /dev/null +++ b/infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 @@ -0,0 +1,25 @@ +[Unit] +Description=Homekeeper — FastAPI +After=network-online.target homekeeper-db.service + +[Container] +Image={{ registry_host }}/{{ registry_user }}/homekeeper-api:latest +ContainerName=homekeeper-api +Network=homekeeper.network +PublishPort=127.0.0.1:8000:8000 +Environment=DB_HOST=homekeeper-db +Environment=DB_PORT=5432 +Environment=DB_NAME={{ db_name }} +Environment=DB_USER={{ db_user }} +Environment=DB_PASSWORD={{ db_password }} +Environment=ROOT_PATH=/api +Environment=INITIAL_USERS={{ initial_users }} +AutoUpdate=registry +Label=io.containers.autoupdate=registry + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 new file mode 100644 index 0000000..c5b5960 --- /dev/null +++ b/infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 @@ -0,0 +1,21 @@ +[Unit] +Description=Homekeeper — Beekeeper (Shiny) +After=network-online.target homekeeper-api.service + +[Container] +Image={{ registry_host }}/{{ registry_user }}/homekeeper-beekeeper:latest +ContainerName=homekeeper-beekeeper +Network=homekeeper.network +PublishPort=127.0.0.1:3838:3838 +Environment=API_URL=http://homekeeper-api:8000 +Environment=API_USER={{ api_user }} +Environment=API_PASS={{ api_pass }} +AutoUpdate=registry +Label=io.containers.autoupdate=registry + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 new file mode 100644 index 0000000..69807ed --- /dev/null +++ b/infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=Homekeeper — PostgreSQL +After=network-online.target + +[Container] +Image=docker.io/library/postgres:17 +ContainerName=homekeeper-db +Network=homekeeper.network +Volume={{ homekeeper_data_dir }}/pg_data:/var/lib/postgresql/data:Z +Environment=POSTGRES_DB={{ db_name }} +Environment=POSTGRES_USER={{ db_user }} +Environment=POSTGRES_PASSWORD={{ db_password }} +HealthCmd=pg_isready -U {{ db_user }} -d {{ db_name }} +HealthInterval=5s +HealthRetries=10 +# No AutoUpdate — avoid surprise postgres major-version upgrades + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 new file mode 100644 index 0000000..1c4de88 --- /dev/null +++ b/infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 @@ -0,0 +1,22 @@ +[Unit] +Description=Homekeeper — Listkeeper (Shiny) +After=network-online.target homekeeper-api.service + +[Container] +Image={{ registry_host }}/{{ registry_user }}/homekeeper-listkeeper:latest +ContainerName=homekeeper-listkeeper +Network=homekeeper.network +PublishPort=127.0.0.1:3839:3839 +Environment=PORT=3839 +Environment=API_URL=http://homekeeper-api:8000 +Environment=API_USER={{ api_user }} +Environment=API_PASS={{ api_pass }} +AutoUpdate=registry +Label=io.containers.autoupdate=registry + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target default.target diff --git a/infrastructure/roles/homekeeper/templates/homekeeper.network.j2 b/infrastructure/roles/homekeeper/templates/homekeeper.network.j2 new file mode 100644 index 0000000..ae30928 --- /dev/null +++ b/infrastructure/roles/homekeeper/templates/homekeeper.network.j2 @@ -0,0 +1,2 @@ +[Network] +Driver=bridge diff --git a/infrastructure/roles/nginx/tasks/main.yml b/infrastructure/roles/nginx/tasks/main.yml new file mode 100644 index 0000000..17c90ed --- /dev/null +++ b/infrastructure/roles/nginx/tasks/main.yml @@ -0,0 +1,50 @@ +--- +- name: Install nginx and certbot + apt: + name: + - nginx + - certbot + - python3-certbot-nginx + state: present + update_cache: true + +- name: Remove default nginx site + file: + path: /etc/nginx/sites-enabled/default + state: absent + notify: Reload nginx + +- name: Deploy homekeeper nginx config + template: + src: homekeeper.conf.j2 + dest: /etc/nginx/sites-available/homekeeper.conf + mode: "0644" + notify: Reload nginx + +- name: Enable homekeeper nginx site + file: + src: /etc/nginx/sites-available/homekeeper.conf + dest: /etc/nginx/sites-enabled/homekeeper.conf + state: link + notify: Reload nginx + +- name: Obtain Let's Encrypt certificates + command: > + certbot --nginx -d {{ domain }} -d {{ gitea_domain }} + --non-interactive --agree-tos -m {{ gitea_admin_email }} + --redirect + args: + creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem + notify: Reload nginx + +- name: Enable nginx + service: + name: nginx + enabled: true + state: started + +handlers: + - name: Reload nginx + service: + name: nginx + state: reloaded diff --git a/infrastructure/roles/nginx/templates/homekeeper.conf.j2 b/infrastructure/roles/nginx/templates/homekeeper.conf.j2 new file mode 100644 index 0000000..6406792 --- /dev/null +++ b/infrastructure/roles/nginx/templates/homekeeper.conf.j2 @@ -0,0 +1,77 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# ── Main app: friessn.de ──────────────────────────────────────────────────── +server { + listen 80; + server_name {{ domain }}; + # certbot --nginx adds SSL redirect + listen 443 block here + + root /var/www/html; + index index.html; + + location = / { + try_files /index.html =404; + } + + location = /api { return 301 /api/; } + location /api/ { + rewrite ^/api/(.*) /$1 break; + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /beekeeper { return 301 /beekeeper/; } + location /beekeeper/ { + rewrite ^/beekeeper/(.*) /$1 break; + proxy_pass http://127.0.0.1:3838; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + location = /listkeeper { return 301 /listkeeper/; } + location /listkeeper/ { + rewrite ^/listkeeper/(.*) /$1 break; + proxy_pass http://127.0.0.1:3839; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} + +# ── Gitea: git.friessn.de ─────────────────────────────────────────────────── +server { + listen 80; + server_name {{ gitea_domain }}; + # certbot --nginx adds SSL redirect + listen 443 block here + + client_max_body_size 512m; + + location / { + proxy_pass http://127.0.0.1:{{ gitea_http_port }}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/infrastructure/roles/podman/tasks/main.yml b/infrastructure/roles/podman/tasks/main.yml new file mode 100644 index 0000000..c6cff62 --- /dev/null +++ b/infrastructure/roles/podman/tasks/main.yml @@ -0,0 +1,59 @@ +--- +- name: Install podman + apt: + name: + - podman + - podman-compose # for ad-hoc use; production uses quadlets + state: present + update_cache: true + +- name: Create homekeeper data directories + file: + path: "{{ item }}" + state: directory + mode: "0750" + loop: + - "{{ homekeeper_data_dir }}" + - "{{ homekeeper_data_dir }}/pg_data" + - "{{ gitea_data_dir }}" + +- name: Configure Gitea as additional registry + template: + src: registries.conf.j2 + dest: /etc/containers/registries.conf.d/gitea.conf + mode: "0644" + notify: Restart podman services + +- name: Login to Gitea container registry + command: > + podman login {{ registry_host }}:{{ registry_port }} + -u {{ registry_user }} -p {{ registry_token }} + register: login_result + changed_when: "'Login Succeeded' in login_result.stdout" + # Run this after Gitea is up and registry_token is set + +- name: Enable podman-auto-update timer + systemd: + name: podman-auto-update.timer + enabled: true + state: started + daemon_reload: true + +- name: Override auto-update timer schedule + copy: + dest: /etc/systemd/system/podman-auto-update.timer.d/override.conf + content: | + [Timer] + OnCalendar= + OnCalendar={{ autoupdate_schedule }} + AccuracySec=1s + mode: "0644" + notify: Reload systemd + +handlers: + - name: Reload systemd + systemd: + daemon_reload: true + + - name: Restart podman services + command: systemctl daemon-reload diff --git a/infrastructure/roles/podman/templates/registries.conf.j2 b/infrastructure/roles/podman/templates/registries.conf.j2 new file mode 100644 index 0000000..d0a9743 --- /dev/null +++ b/infrastructure/roles/podman/templates/registries.conf.j2 @@ -0,0 +1,3 @@ +[[registry]] +location = "{{ registry_host }}:{{ registry_port }}" +insecure = false diff --git a/infrastructure/site.yml b/infrastructure/site.yml new file mode 100644 index 0000000..27c0182 --- /dev/null +++ b/infrastructure/site.yml @@ -0,0 +1,10 @@ +--- +- name: Homekeeper server setup + hosts: homekeeper + become: true + roles: + - common + - podman + - gitea + - homekeeper + - nginx diff --git a/listkeeper/DESCRIPTION b/listkeeper/DESCRIPTION new file mode 100644 index 0000000..4a0dc0d --- /dev/null +++ b/listkeeper/DESCRIPTION @@ -0,0 +1,14 @@ +Package: listkeeper +Title: Listen- und Aufgabenverwaltung +Version: 0.2.0 +Authors@R: person("Nico", "Friess", email = "nico.friess@googlemail.com", role = c("aut", "cre")) +Description: Shiny-Anwendung zur Verwaltung von Einkaufslisten, Aufgaben und Projekten. +License: MIT + file LICENSE +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 +Imports: + bslib, + httr2, + jsonlite, + shiny diff --git a/listkeeper/Dockerfile b/listkeeper/Dockerfile new file mode 100644 index 0000000..41799a8 --- /dev/null +++ b/listkeeper/Dockerfile @@ -0,0 +1,15 @@ +FROM rocker/tidyverse:4.4.2 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY DESCRIPTION /pkg/DESCRIPTION +RUN Rscript -e "remotes::install_deps('/pkg', dependencies = TRUE)" + +COPY . /pkg +RUN R CMD INSTALL /pkg + +EXPOSE 3838 + +CMD ["Rscript", "-e", "listkeeper::run_app()"] diff --git a/listkeeper/NAMESPACE b/listkeeper/NAMESPACE new file mode 100644 index 0000000..2736e98 --- /dev/null +++ b/listkeeper/NAMESPACE @@ -0,0 +1,3 @@ +export(run_app) +import(shiny) +import(bslib) diff --git a/listkeeper/NEWS.md b/listkeeper/NEWS.md new file mode 100644 index 0000000..85f52ac --- /dev/null +++ b/listkeeper/NEWS.md @@ -0,0 +1,10 @@ +# listkeeper 0.2.0 + +- API-Schicht (FastAPI) zwischen App und Datenbank; alle Zugriffe über httr2 +- Performance: Item-Counts per Subquery im Listen-Endpoint, Items werden lazy beim Aufklappen geladen +- Logging: JSONL-Logdatei in `./logs/YYYY-MM-DD_log.jsonl` +- Port über `PORT`-Umgebungsvariable konfigurierbar + +# listkeeper 0.1.0 + +- Initiale Version: Einkaufslisten, Aufgabenlisten, Projekte mit T-Shirt-Größen diff --git a/listkeeper/R/api_client.R b/listkeeper/R/api_client.R new file mode 100644 index 0000000..626fbc4 --- /dev/null +++ b/listkeeper/R/api_client.R @@ -0,0 +1,88 @@ +.api_req <- function(api, path) { + httr2::request(paste0(api$base_url, path)) |> + httr2::req_auth_basic(api$user, api$pass) +} + +.api_check <- function(resp, path) { + status <- httr2::resp_status(resp) + if (status >= 400) { + body <- tryCatch(httr2::resp_body_string(resp), error = function(e) "") + log_error("api_error", path = path, status = status, body = body) + } + httr2::resp_check_status(resp) +} + +api_get <- function(api, path, query = NULL) { + req <- .api_req(api, path) + if (!is.null(query) && length(query) > 0) { + query <- Filter(Negate(is.null), query) + if (length(query) > 0) req <- do.call(httr2::req_url_query, c(list(req), query)) + } + resp <- httr2::req_error(req, is_error = \(r) FALSE) |> httr2::req_perform() + if (httr2::resp_status(resp) == 404) return(data.frame()) + .api_check(resp, path) + result <- httr2::resp_body_json(resp, simplifyVector = TRUE) + if (is.null(result) || length(result) == 0) return(data.frame()) + if (is.data.frame(result)) return(result) + as.data.frame(result, stringsAsFactors = FALSE) +} + +api_get_one <- function(api, path, query = NULL) { + req <- .api_req(api, path) + if (!is.null(query) && length(query) > 0) { + query <- Filter(Negate(is.null), query) + if (length(query) > 0) req <- do.call(httr2::req_url_query, c(list(req), query)) + } + resp <- httr2::req_error(req, is_error = \(r) FALSE) |> httr2::req_perform() + if (httr2::resp_status(resp) %in% c(404, 204)) return(data.frame()) + .api_check(resp, path) + result <- httr2::resp_body_json(resp, simplifyVector = FALSE) + if (is.null(result) || length(result) == 0) return(data.frame()) + as.data.frame(lapply(result, function(x) if (is.null(x)) NA else x), stringsAsFactors = FALSE) +} + +api_post <- function(api, path, body = list()) { + log_debug("api_post", path = path) + resp <- .api_req(api, path) |> + httr2::req_body_json(body) |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + if (httr2::resp_content_type(resp) == "" || httr2::resp_status(resp) == 204) + return(invisible(NULL)) + result <- httr2::resp_body_json(resp, simplifyVector = FALSE) + if (is.null(result)) return(invisible(NULL)) + result +} + +api_put <- function(api, path, body = list()) { + log_debug("api_put", path = path) + resp <- .api_req(api, path) |> + httr2::req_method("PUT") |> + httr2::req_body_json(body) |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + invisible(NULL) +} + +api_patch <- function(api, path, body = list()) { + log_debug("api_patch", path = path) + resp <- .api_req(api, path) |> + httr2::req_method("PATCH") |> + httr2::req_body_json(body) |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + invisible(NULL) +} + +api_delete <- function(api, path) { + log_debug("api_delete", path = path) + resp <- .api_req(api, path) |> + httr2::req_method("DELETE") |> + httr2::req_error(is_error = \(r) FALSE) |> + httr2::req_perform() + .api_check(resp, path) + invisible(NULL) +} diff --git a/listkeeper/R/app.R b/listkeeper/R/app.R new file mode 100644 index 0000000..aba9dba --- /dev/null +++ b/listkeeper/R/app.R @@ -0,0 +1,21 @@ +#' @export +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) + } + + app <- shiny::shinyApp(ui = ui(), server = server_fn) + shiny::runApp(app, host = host, port = port, launch.browser = FALSE) +} diff --git a/listkeeper/R/db_items.R b/listkeeper/R/db_items.R new file mode 100644 index 0000000..b1683b4 --- /dev/null +++ b/listkeeper/R/db_items.R @@ -0,0 +1,15 @@ +db_items_by_list <- function(db, list_id) { + api_get(db, paste0("/v1/lists/", list_id, "/items")) +} + +db_items_insert <- function(db, list_id, text) { + api_post(db, paste0("/v1/lists/", list_id, "/items"), list(text = text)) +} + +db_items_set_checked <- function(db, id, checked) { + api_patch(db, paste0("/v1/lists/items/", id), list(checked = isTRUE(checked))) +} + +db_items_delete <- function(db, id) { + api_delete(db, paste0("/v1/lists/items/", id)) +} diff --git a/listkeeper/R/db_lists.R b/listkeeper/R/db_lists.R new file mode 100644 index 0000000..4772bd3 --- /dev/null +++ b/listkeeper/R/db_lists.R @@ -0,0 +1,24 @@ +db_lists_get_all <- function(db, include_closed = FALSE) { + api_get(db, "/v1/lists", list(include_closed = include_closed)) +} + +db_lists_insert <- function(db, name, type, t_shirt_size = NA, notes = NA) { + api_post(db, "/v1/lists", list( + name = name, + type = type, + t_shirt_size = t_shirt_size, + notes = notes + )) +} + +db_lists_close <- function(db, id) { + api_post(db, paste0("/v1/lists/", id, "/close")) +} + +db_lists_reopen <- function(db, id) { + api_post(db, paste0("/v1/lists/", id, "/reopen")) +} + +db_lists_delete <- function(db, id) { + api_delete(db, paste0("/v1/lists/", id)) +} diff --git a/listkeeper/R/db_migrate.R b/listkeeper/R/db_migrate.R new file mode 100644 index 0000000..07ba648 --- /dev/null +++ b/listkeeper/R/db_migrate.R @@ -0,0 +1 @@ +db_migrate <- function(db) invisible(NULL) diff --git a/listkeeper/R/logger.R b/listkeeper/R/logger.R new file mode 100644 index 0000000..d9c19a1 --- /dev/null +++ b/listkeeper/R/logger.R @@ -0,0 +1,36 @@ +.log_env <- new.env(parent = emptyenv()) +.log_env$file <- NULL + +log_init <- function() { + log_dir <- Sys.getenv("LOG_DIR", "/logs") + if (!dir.exists(log_dir)) + dir.create(log_dir, recursive = TRUE, showWarnings = FALSE) + fname <- paste0(format(Sys.Date(), "%Y-%m-%d"), "_log.jsonl") + .log_env$file <- file.path(log_dir, fname) + + v <- tryCatch( + as.character(utils::packageVersion("listkeeper")), + error = function(e) "unknown" + ) + .log_write("INFO", "startup", + version = v, + r_version = paste(R.version$major, R.version$minor, sep = "."), + platform = R.version$platform, + api_url = Sys.getenv("API_URL", "http://localhost:8000") + ) +} + +.log_write <- function(level, msg, ...) { + if (is.null(.log_env$file)) return(invisible(NULL)) + entry <- c( + list(ts = format(Sys.time(), "%Y-%m-%dT%H:%M:%OS3"), level = level, msg = msg), + list(...) + ) + line <- jsonlite::toJSON(entry, auto_unbox = TRUE) + cat(line, "\n", file = .log_env$file, append = TRUE, sep = "") + invisible(NULL) +} + +log_info <- function(msg, ...) .log_write("INFO", msg, ...) +log_debug <- function(msg, ...) .log_write("DEBUG", msg, ...) +log_error <- function(msg, ...) .log_write("ERROR", msg, ...) diff --git a/listkeeper/R/screen_home.R b/listkeeper/R/screen_home.R new file mode 100644 index 0000000..4c19106 --- /dev/null +++ b/listkeeper/R/screen_home.R @@ -0,0 +1,301 @@ +screen_home_ui <- function(id) { + ns <- NS(id) + shiny::tagList( + shiny::uiOutput(ns("filter_row")), + shiny::div(class = "mb-3", + shiny::checkboxInput(ns("show_closed"), "Erledigte anzeigen", value = FALSE) + ), + shiny::uiOutput(ns("list_cards")), + shiny::br(), + shiny::actionButton(ns("new_btn"), + shiny::tagList(shiny::icon("plus"), " Neue Liste"), + class = "btn btn-primary w-100", + style = "min-height:50px" + ) + ) +} + +screen_home_server <- function(id, db) { + moduleServer(id, function(input, output, session) { + ns <- session$ns + reload <- reactiveVal(0) + type_filter <- reactiveVal("") + expanded <- reactiveVal(integer(0)) + + # ---- Filter chips -------------------------------------------------------- + FILTERS <- list( + list(lbl = "Alle", val = ""), + list(lbl = "Einkaufen", val = "shopping"), + list(lbl = "Aufgaben", val = "todo"), + list(lbl = "Projekte", val = "project") + ) + + output$filter_row <- renderUI({ + cur <- type_filter() + shiny::div(class = "d-flex gap-2 mb-3 flex-wrap", + lapply(seq_along(FILTERS), function(i) { + f <- FILTERS[[i]] + active <- identical(cur, f$val) + shiny::actionButton(ns(paste0("f_", i)), f$lbl, + class = if (active) "btn btn-primary btn-sm" else "btn btn-outline-secondary btn-sm" + ) + }) + ) + }) + + observe({ + lapply(seq_along(FILTERS), function(i) { + local({ + li <- i; val <- FILTERS[[li]]$val + observeEvent(input[[paste0("f_", li)]], type_filter(val), ignoreInit = TRUE) + }) + }) + }) + + # ---- Data ---------------------------------------------------------------- + lists <- reactive({ + reload() + db_lists_get_all(db, include_closed = isTRUE(input$show_closed)) + }) + + filtered <- reactive({ + ls <- lists() + tf <- type_filter() + if (!is.null(tf) && tf != "") ls[ls$type == tf, ] else ls + }) + + # ---- Card interactions --------------------------------------------------- + observeEvent(input$expand, { + id <- as.integer(input$expand) + cur <- expanded() + expanded(if (id %in% cur) setdiff(cur, id) else c(cur, id)) + }) + + observeEvent(input$item_toggle, { + req(input$item_toggle) + db_items_set_checked(db, + id = as.integer(input$item_toggle$id), + checked = isTRUE(input$item_toggle$checked) + ) + reload(reload() + 1L) + }) + + observeEvent(input$add_item, { + req(input$add_item) + txt <- trimws(input$add_item$text %||% "") + if (nchar(txt) == 0) return() + db_items_insert(db, + list_id = as.integer(input$add_item$list_id), + text = txt + ) + reload(reload() + 1L) + }) + + observeEvent(input$close_list, { + req(input$close_list) + db_lists_close(db, id = as.integer(input$close_list)) + reload(reload() + 1L) + }) + + observeEvent(input$reopen_list, { + req(input$reopen_list) + db_lists_reopen(db, id = as.integer(input$reopen_list)) + reload(reload() + 1L) + }) + + observeEvent(input$delete_list, { + req(input$delete_list) + db_lists_delete(db, id = as.integer(input$delete_list)) + expanded(setdiff(expanded(), as.integer(input$delete_list))) + reload(reload() + 1L) + }) + + # ---- Render cards -------------------------------------------------------- + output$list_cards <- renderUI({ + ls <- filtered() + exp <- expanded() + + if (nrow(ls) == 0) + return(shiny::p(class = "text-muted text-center py-4", "Keine Listen vorhanden.")) + + lapply(seq_len(nrow(ls)), function(i) { + row <- ls[i, ] + is_exp <- row$id %in% exp + is_cls <- !is.na(row$closed_at) + n_tot <- as.integer(row$item_count %||% 0L) + n_chk <- as.integer(row$checked_count %||% 0L) + items <- if (is_exp) db_items_by_list(db, row$id) else data.frame() + + type_cls <- switch(row$type, + shopping = "bg-warning text-dark", + todo = "bg-primary", + project = "bg-info text-dark", + "bg-secondary" + ) + type_lbl <- switch(row$type, + shopping = "Einkaufen", + todo = "Aufgaben", + project = "Projekt", + row$type + ) + + # Header (always visible, tappable to expand) + hdr <- shiny::div( + class = paste("tap-card d-flex justify-content-between align-items-center py-2 px-3", + if (is_cls) "bg-light" else "bg-white"), + style = "border-bottom: 1px solid rgba(0,0,0,.125);", + onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", ns("expand"), row$id), + shiny::div(class = "d-flex align-items-center gap-2 overflow-hidden", + shiny::HTML(sprintf('%s', type_cls, type_lbl)), + shiny::tags$strong( + class = paste("text-truncate", if (is_cls) "text-muted" else ""), + row$name + ), + if (!is.na(row$t_shirt_size)) + shiny::HTML(sprintf('%s', row$t_shirt_size)) + else NULL + ), + shiny::div(class = "d-flex align-items-center gap-2 flex-shrink-0 ms-2", + shiny::tags$small(class = "text-muted", + if (n_tot > 0) sprintf("%d/%d", n_chk, n_tot) else "leer" + ), + shiny::icon(if (is_exp) "chevron-up" else "chevron-down", class = "text-muted") + ) + ) + + # Body (only when expanded) + bdy <- if (is_exp) { + item_rows <- if (nrow(items) > 0) { + lapply(seq_len(n_tot), function(j) { + it <- items[j, ] + shiny::div( + class = "item-row d-flex align-items-start gap-2 py-2 px-3", + shiny::tags$input( + type = "checkbox", + class = "form-check-input mt-1 flex-shrink-0", + style = "width:1.1rem;height:1.1rem;cursor:pointer", + checked = if (isTRUE(it$checked)) NA else NULL, + onclick = sprintf( + "Shiny.setInputValue('%s',{id:%d,checked:this.checked},{priority:'event'})", + ns("item_toggle"), it$id + ) + ), + shiny::tags$span( + class = if (isTRUE(it$checked)) + "flex-grow-1 text-muted text-decoration-line-through" + else "flex-grow-1", + it$text + ) + ) + }) + } else { + list(shiny::p(class = "text-muted small px-3 py-2 mb-0", "Noch keine Einträge.")) + } + + inp_id <- sprintf("lk_inp_%d", row$id) + add_row <- shiny::div(class = "d-flex gap-2 px-3 pt-2 pb-1", + shiny::tags$input( + type = "text", + id = inp_id, + class = "form-control form-control-sm", + placeholder = "Neuer Eintrag...", + 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 + ) + ), + shiny::tags$button( + type = "button", + class = "btn btn-outline-primary btn-sm flex-shrink-0", + onclick = sprintf( + "(function(i){var v=i.value.trim();if(v){Shiny.setInputValue('%s',{list_id:%d,text:v,ts:Date.now()},{priority:'event'});i.value='';}})(document.getElementById('%s'))", + ns("add_item"), row$id, inp_id + ), + shiny::icon("plus") + ) + ) + + footer_btns <- shiny::div( + class = "d-flex justify-content-between align-items-center px-3 py-2 bg-light border-top", + if (!is_cls) { + shiny::tags$button( + type = "button", + class = "btn btn-success btn-sm", + onclick = sprintf("Shiny.setInputValue('%s',%d,{priority:'event'})", ns("close_list"), row$id), + shiny::icon("check"), " Erledigt" + ) + } else { + shiny::tags$button( + type = "button", + class = "btn btn-outline-secondary btn-sm", + onclick = sprintf("Shiny.setInputValue('%s',%d,{priority:'event'})", ns("reopen_list"), row$id), + shiny::icon("undo"), " Wieder öffnen" + ) + }, + shiny::tags$button( + type = "button", + class = "btn btn-outline-danger btn-sm", + onclick = sprintf( + "if(confirm('Liste und alle Einträge löschen?'))Shiny.setInputValue('%s',%d,{priority:'event'})", + ns("delete_list"), row$id + ), + shiny::icon("trash") + ) + ) + + shiny::div( + shiny::tagList(item_rows), + add_row, + footer_btns + ) + } else NULL + + shiny::div(class = "card mb-2 shadow-sm", hdr, bdy) + }) + }) + + # ---- New list modal ------------------------------------------------------ + observeEvent(input$new_btn, { + shiny::showModal(shiny::modalDialog( + title = "Neue Liste", + footer = shiny::tagList( + shiny::modalButton("Abbrechen"), + shiny::actionButton(ns("confirm_new"), "Erstellen", class = "btn-primary") + ), + shiny::textInput(ns("new_name"), "Name *"), + shiny::selectInput(ns("new_type"), "Typ", + choices = c("Einkaufen" = "shopping", "Aufgaben" = "todo", "Projekt" = "project"), + selected = "shopping" + ), + shiny::uiOutput(ns("new_extra_ui")) + )) + }) + + output$new_extra_ui <- renderUI({ + req(input$new_type) + if (input$new_type == "project") { + shiny::selectInput(ns("new_size"), "Aufwand (T-Shirt-Größe)", + choices = c("XS", "S", "M", "L", "XL"), + selected = "M" + ) + } + }) + + observeEvent(input$confirm_new, { + name <- trimws(input$new_name %||% "") + if (nchar(name) == 0) { + shiny::showNotification("Bitte einen Namen eingeben.", type = "warning", duration = 3) + return() + } + t_size <- if (!is.null(input$new_type) && input$new_type == "project") + input$new_size else NA + db_lists_insert(db, + name = name, + type = input$new_type, + t_shirt_size = t_size + ) + shiny::removeModal() + reload(reload() + 1L) + }) + }) +} diff --git a/listkeeper/R/server.R b/listkeeper/R/server.R new file mode 100644 index 0000000..0f176a0 --- /dev/null +++ b/listkeeper/R/server.R @@ -0,0 +1,3 @@ +server <- function(input, output, session, db) { + screen_home_server("s_home", db = db) +} diff --git a/listkeeper/R/ui.R b/listkeeper/R/ui.R new file mode 100644 index 0000000..f498075 --- /dev/null +++ b/listkeeper/R/ui.R @@ -0,0 +1,35 @@ +ui <- function() { + bslib::page( + fillable = FALSE, + title = "Listen", + theme = bslib::bs_theme(version = 5), + shiny::tags$head( + shiny::tags$meta(name = "viewport", content = "width=device-width, initial-scale=1"), + shiny::tags$style(shiny::HTML(" + body { background: #f8f9fa !important; } + .bk-header { + background: #fff; + border-bottom: 1px solid #dee2e6; + padding: .75rem 1rem; + position: sticky; + top: 0; + z-index: 100; + } + .tap-card { cursor: pointer; } + .tap-card:active { opacity: .8; } + .card { overflow: hidden; } + .item-row { border-bottom: 1px solid #f0f0f0; } + .item-row:last-child { border-bottom: none; } + #ss-connect-dialog, .shiny-disconnected-overlay { display: none !important; } + ")) + ), + shiny::div( + class = "bk-header d-flex justify-content-between align-items-center", + shiny::tags$h4(class = "mb-0", "Listen") + ), + shiny::div( + class = "container-fluid p-3", + screen_home_ui("s_home") + ) + ) +} diff --git a/listkeeper/R/utils.R b/listkeeper/R/utils.R new file mode 100644 index 0000000..aba3003 --- /dev/null +++ b/listkeeper/R/utils.R @@ -0,0 +1 @@ +`%||%` <- function(a, b) if (!is.null(a) && length(a) > 0 && !is.na(a[1])) a else b diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..1e1201b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,86 @@ +events { + worker_connections 1024; +} + +http { + # Docker's internal DNS — resolve upstream names dynamically so container IP + # changes after rebuilds don't require an nginx restart. + resolver 127.0.0.11 valid=10s ipv6=off; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + location = / { + try_files /index.html =404; + } + + # Redirect /listkeeper → /listkeeper/ + location = /listkeeper { + return 301 /listkeeper/; + } + + location /listkeeper/ { + set $up_listkeeper http://listkeeper:3838; + rewrite ^/listkeeper/(.*) /$1 break; + proxy_pass $up_listkeeper; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + location = /api { + return 301 /api/; + } + + location /api/ { + set $up_api http://api:8000; + rewrite ^/api/(.*) /$1 break; + proxy_pass $up_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Redirect /beekeeper → /beekeeper/ + location = /beekeeper { + return 301 /beekeeper/; + } + + location /beekeeper/ { + set $up_beekeeper http://beekeeper:3838; + rewrite ^/beekeeper/(.*) /$1 break; + proxy_pass $up_beekeeper; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + } +} diff --git a/todos.md b/todos.md new file mode 100644 index 0000000..fac145e --- /dev/null +++ b/todos.md @@ -0,0 +1,49 @@ +# Todos + +## beekeeper + +### Karte +- [ ] Karte bei Bienenstand-Erstellung: Standort per Klick auf Karte setzen (Punkt setzen statt nur Koordinaten eingeben) +- [ ] Karte: Umschaltung auf Satellitenbild ermöglichen +- [ ] Startseite: Karte mit allen Bienenständen als Markercluster (zum Reinzoomen) + +### Capacitor +- [ ] Android-Zurück-Button: soll innerhalb der App zurücknavigieren statt die App zu schließen (`backbutton`-Event abfangen) + +### Volk +- [ ] Status-Filter-Buttons umbrechen nicht obwohl `flex-wrap` gesetzt ist — debuggen warum (vermutlich `overflow:hidden` oder Button-Breite zwingt Zeile) +- [ ] Volk-ID: beim Anlegen eine eindeutige ID erzeugen (DB-Primärschlüssel), dezent anzeigen +- [ ] Namensauswahl: Würfel-Icon für zufälligen Völkernamen (lustige/nerdige API recherchieren) +- [ ] Bug: Klick auf „Anlegen" im Volk-Dialog tut nichts / hängt — zwei mögliche Ursachen: + 1. `req(nav$apiary_id)` in `screen_colonies.R:110` bricht still ab wenn kein Bienenstand aktiv ist + 2. `dbGetQuery` mit `INSERT ... RETURNING id` (PostgreSQL-Migration) könnte für RPostgres `dbSendQuery`+`dbFetch` brauchen + +### Header / Navigation +- [ ] Custom-Header optisch aufpolieren — kein `page_navbar()` (falsch für Stack-Navigation), aber Header-Leiste wirkt roh; Höhe, Schrift und Abstände mit bslib-Werten angleichen +- [ ] Startseite: Hamburger/Settings-Icon für globale Aktionen (Einstellungen, Betriebsinfos) — `page_navbar()` braucht es dafür nicht, reicht ein Icon-Button rechts im Header + +### Bienenstand +- [ ] Völker-Liste einklappbar machen +- [ ] Stand-Ansicht: Bearbeiten-Funktion und eigene ToDos je Bienenstand + +### Varroa +- [ ] Totenfall-Messung: Anfangs- und Enddatum erfassen, daraus Milben/Tag berechnen — Wert entscheidet über Behandlungsempfehlung +- [ ] Behandlung: Selectize für Behandlungsmittel (mind. Ameisensäure, Oxalsäure), Anwendungsweise abhängig vom gewählten Mittel +- [ ] Varroa-Kontrolle vereinheitlichen: Messung und Nachkontrolle in einem Reiter „Varroakontrolle"; Nachkontrolle per Checkbox auswählbar, dabei Behandlung wählbar (Default: letzte Behandlung) + +### Durchsicht +- [ ] Freitextfeld (Notizen) weiter nach oben, da meist zuerst befüllt +- [ ] Abschnitte (z. B. Zuchteigenschaften) einklappbar machen — werden nicht immer ausgefüllt + +### Architektur & Hierarchie +- [ ] Hierarchie klar trennen: **Imkerei** → **Bienenstand** → **Volk** (Volk kann Bienenstand wechseln) +- [ ] Betriebsebene (Imkerei gesamt) als eigenen Bereich einführen, dort gehören hin: + - Betriebsbuch (inkl. Varroabehandlungs-Protokoll) + - ToDo-Listen + - Materialinventur + - Honigernte / Schleudern (weil Honig aus mehreren Völkern zusammenkommt) + - Produktvermarktung +- [ ] Auf Volksebene: „Honigentnahme" erfassen (welche Völker wurden geschleudert) — Ernte selbst auf Imkerei-Ebene + +### Allgemein +- [x] Shiny-Session-Timeout erhöhen — `shiny.disconnectDelay = 60000` gesetzt diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..aea64cf --- /dev/null +++ b/www/index.html @@ -0,0 +1,109 @@ + + + + + + Homekeeper + + + +
+

Homekeeper

+
+
+

Apps

+ +
+ +