add homekeeper stack

This commit is contained in:
2026-07-06 19:25:38 +02:00
commit 58983e6855
90 changed files with 6816 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
data/
pg_data/
*.db
.Rhistory
.RData
.Rproj.user/
beekeeper/man/
beekeeper/logs/
listkeeper/logs/
+308
View File
@@ -0,0 +1,308 @@
# CLAUDE.md
Guidance for Claude Code when working in this repository.
## Platform Overview
**Homekeeper** — self-hosted platform for a *Selbstversorgerhof* (self-sufficiency farm). Multiple focused Shiny apps behind one reverse proxy, all sharing one PostgreSQL database.
**Owner:** Nico Friess (nico.friess@googlemail.com). UI language: German. Respond in German when Nico writes in German.
| App | Route | R package | Purpose |
|---|---|---|---|
| **Beekeeper** | `/beekeeper/` | `./beekeeper/` | Beehive management (Imkerei) |
| **Listkeeper** | `/listkeeper/` | `./listkeeper/` | Shopping lists, to-dos, projects |
Planned: Garten, Tiere, Haus & Hof.
All UI must work on mobile (Android). Bootstrap 5, no fixed pixel widths.
---
## Architecture
```
Browser
└── nginx (reverse proxy)
├── /beekeeper/ → Shiny :3838
├── /listkeeper/ → Shiny :3839
└── /api/ → FastAPI :8000
└── PostgreSQL :5432
```
**Shiny apps do not touch the database directly.** All data access goes through the FastAPI service via `httr2` (see API Client Pattern below). The `db_<entity>.R` files in each R package are thin wrappers around API calls — there is no DBI anywhere in the Shiny code.
**FastAPI** (`./api/`) handles schema creation (`init_db()` on startup), auth (HTTP Basic), and all SQL. It exposes a versioned REST API at `/v1/`.
---
## Local Development
```bash
# Start everything (builds images on first run)
docker compose up --build -d
# Useful
docker compose logs -f beekeeper
docker compose logs -f listkeeper
docker compose logs -f api
docker compose up -d --build beekeeper # rebuild one service
docker compose up -d --force-recreate nginx # reload nginx config without rebuild
docker compose down
# App logs (JSONL, written to host via volume)
tail -f beekeeper/logs/$(date +%Y-%m-%d)_log.jsonl
tail -f listkeeper/logs/$(date +%Y-%m-%d)_log.jsonl
```
Local URLs: `http://localhost/beekeeper/`, `http://localhost/listkeeper/`, `http://localhost/api/docs`
---
## Repository Structure
```
. ← repo root (will rename to homekeeper later)
├── docker-compose.yml
├── nginx.conf ← local reverse proxy; routes to Docker service names
├── pg_data/ ← PostgreSQL data (bind mount, gitignored)
├── www/ ← landing page served at /
│ └── index.html
├── api/ ← FastAPI service (Python)
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app/
│ ├── main.py ← lifespan, routers, root_path
│ ├── database.py ← engine, get_session, init_db()
│ ├── auth.py ← HTTP Basic auth, seed_users()
│ ├── models/
│ └── routers/ ← one file per resource
├── beekeeper/ ← R package
│ ├── Dockerfile
│ ├── DESCRIPTION ← version here
│ ├── NEWS.md
│ ├── logs/ ← JSONL logs (gitignored)
│ └── R/
├── listkeeper/ ← R package
│ ├── Dockerfile
│ ├── DESCRIPTION
│ ├── NEWS.md
│ ├── logs/
│ └── R/
└── infrastructure/ ← Ansible for Hetzner VM
├── site.yml
├── inventory/hosts.yml
├── group_vars/all.yml
└── roles/
├── common/ ← apt, ufw, fail2ban
├── podman/ ← install podman, auto-update timer
├── nginx/ ← system nginx + certbot (Let's Encrypt)
├── gitea/ ← Gitea as Podman Quadlet (git + container registry)
└── homekeeper/ ← app Quadlets (db, api, beekeeper, listkeeper)
```
---
## Production (Hetzner VM)
Target: Ubuntu 24.04, Podman + systemd Quadlets, system nginx.
| Domain | Purpose |
|---|---|
| `home.friessn.de` | Landing page + all apps |
| `git.friessn.de` | Gitea (git hosting + container registry) |
**Deployment flow:** push code → build images locally → `docker push git.friessn.de/nico/homekeeper-<app>:latest``podman auto-update` on VM pulls new image and restarts container (runs every 10s via systemd timer).
**Run the Ansible playbook:**
```bash
cd infrastructure
ansible-playbook -i inventory/hosts.yml site.yml
```
First-time setup has a Gitea bootstrap step — see `infrastructure/README.md`.
**Secrets:** all `CHANGE_ME` values in `group_vars/all.yml` must be set before first deploy. Use `ansible-vault encrypt_string` for passwords.
The local `docker-compose.yml` is for development only. Production uses Quadlet files in `infrastructure/roles/homekeeper/templates/`.
---
## Docker / Podman Services
| Service | Image | Port (internal) | Notes |
|---|---|---|---|
| `db` | `postgres:17` | 5432 | data in `./pg_data/` |
| `api` | built from `./api/` | 8000 | FastAPI, ROOT_PATH=/api |
| `beekeeper` | built from `./beekeeper/` | 3838 | Shiny |
| `listkeeper` | built from `./listkeeper/` | 3838 (local) / 3839 (prod) | Shiny, PORT env var |
| `nginx` | `nginx:alpine` | 80 | local only; system nginx on VM |
**ENV vars (all services read from docker-compose / Quadlet):**
- `API_URL`, `API_USER`, `API_PASS` — Shiny apps
- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH`, `INITIAL_USERS` — API
- `LOG_DIR` — Shiny apps (default `/logs`, mounted from `./*/logs/`)
- `PORT` — listkeeper only (default 3838)
---
## FastAPI (`./api/`)
Python 3.12, FastAPI + SQLAlchemy (raw SQL via `session.execute(text(...))`). No ORM queries — complex joins written by hand.
**Auth:** HTTP Basic. Users stored in `auth.users` (bcrypt). Seeded from `INITIAL_USERS` env var (`"user1:pass1,user2:pass2"`) on first startup.
**Adding an endpoint:** add a file to `app/routers/`, include it in `main.py`. All routes return `dict(r._mapping)` from raw SQL rows.
**Performance pattern:** avoid N+1 — use subqueries or JOINs to fetch related data in one query. Example: `colonies` list endpoint includes `last_inspection_date` as a subquery and `item_count`/`checked_count` in the lists endpoint.
---
## R Package Pattern
Plain Shiny — no golem, no rhino. Each package exports one function: `run_app()`.
**File naming:**
| File | Purpose |
|---|---|
| `app.R` | `run_app()` — calls `log_init()`, builds `api` list, starts `shinyApp()` |
| `ui.R` | `ui()``bslib::page(fillable=FALSE)` with sticky header |
| `server.R` | `server()` — initializes modules, navigation switch |
| `screen_<name>.R` | One module per screen: `screen_<name>_ui()` + `screen_<name>_server()` |
| `db_<entity>.R` | API wrapper functions — call `api_get/post/put/patch/delete()` |
| `api_client.R` | HTTP helpers: `api_get`, `api_get_one`, `api_post`, `api_put`, `api_patch`, `api_delete` |
| `logger.R` | `log_init()`, `log_info()`, `log_debug()`, `log_error()` — writes JSONL |
| `utils.R` | `%||%`, constants, badge helpers |
| `widgets.R` | Reusable UI components: `stepper_row`, `toggle_row`, `section_card`, etc. |
**`app.R` pattern:**
```r
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
log_init()
api <- list(
base_url = Sys.getenv("API_URL", "http://localhost:8000"),
user = Sys.getenv("API_USER", "homestead"),
pass = Sys.getenv("API_PASS", "homestead")
)
server_fn <- function(input, output, session) {
log_info("session_start", session_id = session$token)
session$onSessionEnded(function() log_info("session_end", session_id = session$token))
server(input, output, session, db = api)
}
shiny::runApp(shiny::shinyApp(ui(), server_fn), host = host, port = port, launch.browser = FALSE)
}
```
**Module pattern:**
```r
screen_example_ui <- function(id) {
ns <- NS(id)
shiny::tagList(...)
}
screen_example_server <- function(id, db, nav) {
moduleServer(id, function(input, output, session) {
ns <- session$ns
# db = api list (base_url, user, pass) — pass to api_get/post/etc.
# nav = reactiveValues(screen=, apiary_id=, colony_id=) in beekeeper
})
}
```
**API client pattern (`db_<entity>.R`):**
```r
db_colonies_list <- function(db, apiary_id = NULL, include_closed = FALSE) {
api_get(db, "/v1/colonies", list(apiary_id = apiary_id, include_closed = include_closed))
}
db_colonies_insert <- function(db, name, apiary_id, ...) {
result <- api_post(db, "/v1/colonies", list(name = name, apiary_id = apiary_id, ...))
result$id
}
```
**Navigation (beekeeper):** stack-based via `nav` reactiveValues. `nav$screen` drives a `switch()` in `output$screen_content`. Back navigation via `BACK` named vector. No `page_navbar()`.
**Beekeeper screens:**
`apiaries → colonies → colony → inspection / varroa / harvest / queens / checklists / actions / lineage`
Plus: `quick_inspection` (Schnelldurchsicht, all colonies at once) and `bulk_action` (Sammelmaßnahme, one action for many colonies).
**UI conventions:**
- `bslib::page(fillable = FALSE)` — scrollable
- Sticky header: `div(class = "bk-header d-flex ...")` with `position: sticky; top: 0`
- Tap cards: `div(class = "card tap-card mb-2", onclick = "Shiny.setInputValue(...)")`
- Bootstrap 5 only — no fixed pixel widths
- `section_card()`, `collapsible_card()`, `stepper_row()`, `toggle_row()` — use widgets from `widgets.R`
**Inline interaction (avoiding ID collisions in `lapply` loops):**
```r
# Buttons: onclick + Shiny.setInputValue instead of actionButton
shiny::tags$button(
onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", ns("select"), row$id)
)
# Text inputs: pass value via JS
shiny::tags$input(
onkeyup = sprintf(
"if(event.key==='Enter'){var v=this.value.trim();if(v){Shiny.setInputValue('%s',{list_id:%d,text:v,ts:Date.now()},{priority:'event'});this.value=''}}",
ns("add_item"), row$id
)
)
```
**Logging:**
```r
log_init() # once at startup — creates today's JSONL file
log_info("msg", k = v) # structured fields as named args
log_debug("msg", ...)
log_error("msg", ...)
```
Log files: `./beekeeper/logs/YYYY-MM-DD_log.jsonl` (host), `/logs/` inside container.
**Versioning:** bump `DESCRIPTION` and add an entry to `NEWS.md` when something meaningful ships. Minor bump (0.x.0) for features, patch (0.0.x) for fixes.
---
## Database Schemas
### `public` — Beekeeper
**`apiaries`**: `id, name, location_text, lat, lng, notes, created_at`
**`colonies`**: `id, apiary_id, name, status, parent_colony_id, current_queen_id, created_at, closed_at`
- `status`: `wirtschaftsvolk | ableger | jungvolk | schwarmstimmung | eingegangen`
- `closed_at IS NULL` = active
**`queens`**: `id, colony_id, year, breed, origin_colony_id, marked_color, notes, introduced_at, superseded_at`
**`inspections`**: `id, colony_id, date, time, inspector, brood_frames, honey_frames, food_stores_kg, colony_strength, queen_seen, eggs_seen, queen_cells_count, notes`
**`inspection_traits`**: `inspection_id, trait, score` — trait: `sanftmut|schwarmtrieb|wabensitz|honigertrag|varroa_hygiene` (14)
**`varroa_counts`**: `id, colony_id, date, time, date_from, method, count, notes` — method: `natuerlicher_totenfall|alkoholwaesche|puderzucker`
**`varroa_treatments`**: `id, colony_id, date, time, product, method, dosage, notes`
**`varroa_controls`**: `id, treatment_id, colony_id, date, result, notes`
**`honey_harvests`**: `id, colony_id, apiary_id, date, time, weight_kg, type, notes`
**`colony_actions`**: `id, colony_id, date, time, type, notes, related_colony_id, created_at`
- `type` is free text. Used values: `ablegerbildung | zwischenbodenableger | brutentnahme | standwechsel | futterung | vereinigung | mittelwand | sonstig`
**`checklists` / `checklist_items` / `checklist_completions`**
Colony log: API endpoint `GET /v1/colonies/{id}/log` — UNION ALL across all event tables, returns `date, type, summary, ref_id, created_at`.
### `lk` — Listkeeper
**`lk.lists`**: `id, name, type, t_shirt_size, status, notes, created_at, closed_at`
- `type`: `shopping | todo | project`
- `t_shirt_size`: `XS|S|M|L|XL` (projects only)
- `closed_at IS NULL` = active
**`lk.list_items`**: `id, list_id, text, checked, position, created_at`
List endpoint returns `item_count` and `checked_count` as aggregated columns (not from a separate query).
### `auth` — API users
**`auth.users`**: `id, username, hashed_password, is_active, created_at` — bcrypt, seeded from `INITIAL_USERS` env var.
+7
View File
@@ -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"]
View File
+67
View File
@@ -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()
+243
View File
@@ -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()
+45
View File
@@ -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"}
View File
+10
View File
@@ -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
View File
+94
View File
@@ -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()
+99
View File
@@ -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()
+144
View File
@@ -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}
+294
View File
@@ -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)
+136
View File
@@ -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()
+164
View File
@@ -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()
+149
View File
@@ -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()
+141
View File
@@ -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()
+284
View File
@@ -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()
+7
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
^.*\.Rproj$
^\.Rproj\.user$
+15
View File
@@ -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
+16
View File
@@ -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()"]
+3
View File
@@ -0,0 +1,3 @@
# Generated by roxygen2: do not edit by hand
export(run_app)
import(shiny)
+15
View File
@@ -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
+88
View File
@@ -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)
}
+21
View File
@@ -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)
}
+32
View File
@@ -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))
}
+32
View File
@@ -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))
}
+48
View File
@@ -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)
}
+52
View File
@@ -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"))
}
+37
View File
@@ -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")
}
+63
View File
@@ -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))
}
+1
View File
@@ -0,0 +1 @@
db_migrate <- function(db) invisible(NULL)
+45
View File
@@ -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))
}
+103
View File
@@ -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))
}
+41
View File
@@ -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, ...)
+164
View File
@@ -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"
})
})
}
+209
View File
@@ -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)
})
})
}
+109
View File
@@ -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"
})
})
}
+180
View File
@@ -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)
})
})
}
+145
View File
@@ -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)
})
})
}
+490
View File
@@ -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)
})
})
}
+59
View File
@@ -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"
})
})
}
+255
View File
@@ -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. 56 kg")),
shiny::tags$tr(shiny::tags$td("Zander Brutraum"), shiny::tags$td("ca. 78 kg")),
shiny::tags$tr(shiny::tags$td("Dadant Brutraum"), shiny::tags$td("ca. 810 kg"))
)
),
shiny::tags$p(class = "mt-2 mb-0", "1 voll verdeckelter Rahmen ≈ 23 kg."),
shiny::tags$p(class = "mb-0 text-muted small", "Winterbedarf: ca. 1520 kg / Volk.")
)
),
sanftmut = list(
title = "Sanftmut (14)",
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 (14)",
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 (14)",
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 (14)",
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 (14)",
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"
})
})
}
+95
View File
@@ -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"
})
})
}
+102
View File
@@ -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"
})
})
}
+113
View File
@@ -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)
)
}
+181
View File
@@ -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"
})
})
}
+101
View File
@@ -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")
)
})
}
+83
View File
@@ -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")
)
)
}
+101
View File
@@ -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(
'<span class="badge bg-%s">%s</span>',
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"
)
+156
View File
@@ -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 (19)",
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("12"), .noWS = "after"), shiny::tags$td("sehr schwaches Volk")),
shiny::tags$tr(shiny::tags$td(shiny::tags$strong("34"), .noWS = "after"), shiny::tags$td("schwaches bis mittleres Volk")),
shiny::tags$tr(shiny::tags$td(shiny::tags$strong("56"), .noWS = "after"), shiny::tags$td("mittleres bis starkes Volk")),
shiny::tags$tr(shiny::tags$td(shiny::tags$strong("79"), .noWS = "after"), shiny::tags$td("sehr starkes Volk"))
)
),
shiny::tags$p(class = "mt-2 mb-0 text-muted small", "Winterüberwinterung: mind. 45 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", ...)
)
)
}
+2
View File
@@ -0,0 +1,2 @@
pkgload::load_all()
beekeeper::run_app()
+17
View File
@@ -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
+73
View File
@@ -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
+90
View File
@@ -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`.
+29
View File
@@ -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
+7
View File
@@ -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
@@ -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
+26
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
[Network]
Driver=bridge
+50
View File
@@ -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
@@ -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;
}
}
@@ -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
@@ -0,0 +1,3 @@
[[registry]]
location = "{{ registry_host }}:{{ registry_port }}"
insecure = false
+10
View File
@@ -0,0 +1,10 @@
---
- name: Homekeeper server setup
hosts: homekeeper
become: true
roles:
- common
- podman
- gitea
- homekeeper
- nginx
+14
View File
@@ -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
+15
View File
@@ -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()"]
+3
View File
@@ -0,0 +1,3 @@
export(run_app)
import(shiny)
import(bslib)
+10
View File
@@ -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
+88
View File
@@ -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)
}
+21
View File
@@ -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)
}
+15
View File
@@ -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))
}
+24
View File
@@ -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))
}
+1
View File
@@ -0,0 +1 @@
db_migrate <- function(db) invisible(NULL)
+36
View File
@@ -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, ...)
+301
View File
@@ -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('<span class="badge %s flex-shrink-0">%s</span>', 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('<span class="badge bg-secondary flex-shrink-0">%s</span>', 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)
})
})
}
+3
View File
@@ -0,0 +1,3 @@
server <- function(input, output, session, db) {
screen_home_server("s_home", db = db)
}
+35
View File
@@ -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")
)
)
}
+1
View File
@@ -0,0 +1 @@
`%||%` <- function(a, b) if (!is.null(a) && length(a) > 0 && !is.na(a[1])) a else b
+86
View File
@@ -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;
}
}
}
+49
View File
@@ -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
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homekeeper</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f8f9fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: #fff;
border-bottom: 1px solid #dee2e6;
padding: 1rem 1.5rem;
}
header h1 {
font-size: 1.25rem;
font-weight: 600;
color: #212529;
}
main {
flex: 1;
padding: 2rem 1.5rem;
max-width: 600px;
width: 100%;
margin: 0 auto;
}
h2 {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6c757d;
margin-bottom: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
}
.tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.75rem;
padding: 1.5rem 1rem;
text-decoration: none;
color: #212529;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
}
.tile:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-1px);
}
.tile:active {
transform: scale(0.97);
}
.tile-icon {
font-size: 2rem;
line-height: 1;
}
.tile-label {
font-size: 0.9375rem;
font-weight: 500;
text-align: center;
}
</style>
</head>
<body>
<header>
<h1>Homekeeper</h1>
</header>
<main>
<h2>Apps</h2>
<div class="grid">
<a class="tile" href="/beekeeper/">
<span class="tile-icon">🐝</span>
<span class="tile-label">Beekeeper</span>
</a>
<a class="tile" href="/listkeeper/">
<span class="tile-icon">📋</span>
<span class="tile-label">Listkeeper</span>
</a>
</div>
</main>
</body>
</html>