PhysioBase: knowledge base, patients and assessments

Single Go binary with SQLite (pure Go). Knowledge base with full-text
search, exercise handouts, patient records, evidence-based assessment
form generated from one schema, re-assessment comparison with deltas,
print views, import of the old standalone assessment page, daily
consistent backups.
AuthorAdmin <admin@gpl-nas.de>
Date
Commit967d8e24c39b78be46addeef7151977309d7fd95
55 files changed, 6028 insertions(+)
▾A.containerignore
@@ -0,0 +1,8 @@
/physiobase
/data
/data-test*
/.git
/.env
/docs
/.idea
*.db*
▾A.env.example
@@ -0,0 +1,8 @@
# Copy to .env, then fill in. compose.yml reads this file with env_file, which
# does no variable interpolation, so a bcrypt hash can be pasted verbatim.
# Do not add quotes; they are not stripped.
#
# Generate the hash with:
# echo 'your-password' | podman run --rm -i localhost/physiobase hash-password
PHYSIOBASE_USERNAME=admin
PHYSIOBASE_PASSWORD_HASH=
▾A.gitignore
@@ -0,0 +1,8 @@
/physiobase
/data
data-test*
.env
*.db
*.db-wal
*.db-shm
.idea
▾A.golangci.yml
@@ -0,0 +1,39 @@
version: "2"
linters:
default: standard # errcheck, govet, ineffassign, staticcheck, unused
enable:
- bodyclose
- errorlint
- gosec
- nilerr
- revive
- sqlclosecheck
- unconvert
- usestdlibvars
settings:
errcheck:
exclude-functions:
- (*database/sql.Tx).Rollback # nach Commit ein No-op
- (*database/sql.DB).Close
- (net/http.ResponseWriter).Write
- (*bytes.Buffer).WriteTo
- os.Remove
- (*database/sql.Rows).Close # Fehler kommen über rows.Err()
gosec:
excludes:
- G104 # doppelt zu errcheck
- G304 # Dateipfade kommen aus der Datenbank, nicht vom Benutzer
revive:
rules:
- name: exported
disabled: true # interne Pakete, deutsche Kommentare
exclusions:
rules:
- path: _test\.go
linters: [ gosec, errcheck, bodyclose ]
formatters:
enable:
- gofumpt
- goimports
▾A.hearthforge-ci.toml
@@ -0,0 +1,51 @@
# Checks and build. Runs on every push to master and on every tag.
image = "docker.io/golang:1.27"
work_dir = "/ci/build"
clone_project_to = "/ci/build/project"
shell_setup = "set -euo pipefail"
timeout = 1800
memory_limit = "3g"
cache = [
{ path = "/go/pkg/mod", max_size = "1g" },
{ path = "/root/.cache/go-build", max_size = "1g" },
]
[on]
push = ["master", "main"]
tag = true
[[steps]]
name = "tools"
run_sh = """
go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
go install golang.org/x/vuln/cmd/govulncheck@latest
"""
[[steps]]
name = "tidy"
# The checkout has no .git, so compare copies instead of using git diff.
run_sh = "cd project && cp go.mod /tmp/go.mod && cp go.sum /tmp/go.sum && go mod tidy && diff /tmp/go.mod go.mod && diff /tmp/go.sum go.sum"
[[steps]]
name = "format"
run_sh = "cd project && test -z \"$(gofumpt -l -extra .)\" || (gofumpt -l -extra . && exit 1)"
[[steps]]
name = "lint"
run_sh = "cd project && golangci-lint run ./..."
[[steps]]
name = "vulncheck"
warn_on_fail = true
run_sh = "cd project && govulncheck ./..."
[[steps]]
name = "test"
run_sh = "cd project && go test -race -count=1 ./..."
[[steps]]
name = "build"
run_sh = "cd project && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o physiobase ./cmd/physiobase"
publish_file = ["/ci/build/project/physiobase"]
▾AContainerfile
@@ -0,0 +1,18 @@
FROM docker.io/library/golang:1.26-alpine AS build
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go vet ./... \
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /physiobase ./cmd/physiobase
# Laufzeit: Alpine + statisches Binary. Templates/CSS sind eingebettet.
FROM docker.io/library/alpine:3.21
RUN apk add --no-cache tzdata
COPY --from=build /physiobase /usr/local/bin/physiobase
ENV PHYSIOBASE_DATA_DIR=/data PHYSIOBASE_PORT=8080 TZ=Europe/Berlin
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["physiobase", "healthcheck"]
# exec-Form: App ist PID 1 und bekommt SIGTERM direkt (sauberes Herunterfahren, WAL-Checkpoint)
ENTRYPOINT ["/usr/local/bin/physiobase"]
▾AREADME.md
@@ -0,0 +1,154 @@
# PhysioBase
A self-hosted knowledge base and documentation tool for physiotherapy. It keeps
conditions, tests, exercises and treatment protocols in one searchable place,
links them to patients and their assessments (Befunde), and prints exercise
handouts and assessment reports.
One static Go binary, one SQLite file, server-rendered pages. The interface is
in German. JavaScript is only used for marking the body chart and a few
conveniences; everything else works without it.
![Knowledge base](docs/preview-wissen.png)
## Features
- **Knowledge base**: conditions, tests/assessments, exercises, treatment
protocols and notes. Markdown content, tags, body regions, images, videos
and PDFs, source references, and links between entries.
- **Search**: full text (SQLite FTS5) with prefix matching and diacritics
folding: `knie` finds *Kniegelenk*, `ubung` finds *Übung*.
- **Exercise handouts**: pick exercises, set dosage and a personal note,
reorder, copy as a template, print or save as PDF (A4 print view).
- **Patients**: assessments, treatment log, linked knowledge entries and home
programmes on one page.
- **Assessment form (Befund)**: ICF-oriented and evidence-based, including
red/yellow flag screening, NRS and pain mechanism, a body chart with symbol
stamps, ROM/strength/girth tables, special tests, PROMs and performance tests,
PSFS, differential diagnoses, and plan rationale. Specialty sections
(orthopaedics, neurology, internal medicine, gynaecology/obstetrics,
urology, paediatrics) open only when they contain data.
- **Re-assessment and comparison**: start a new assessment as a copy of the
last one. Compare any two assessments; table rows are matched (for example
joint and movement) and numeric changes are shown as Δ, including
Neutral-Null notation (`0-5-100` → `0-0-125` gives `-5 / +25`).
- **Print view**: only filled fields, body chart with legend, optional
treatment log, signature lines.
- **Import**: JSON exports of the previous standalone assessment page
(`Befund_*.json`) and of PhysioBase itself.
- **Backups**: consistent SQLite snapshots (`VACUUM INTO`) at start and every
24 hours, with a configurable number of kept copies.
![Assessment with body chart](docs/preview-befund.png)
## Stack
- **Go** standard library, including the `net/http` router and `html/template`
- **SQLite** through [modernc.org/sqlite](https://modernc.org/sqlite) (pure Go, no cgo), FTS5 for search
- **goldmark** for Markdown (raw HTML disabled)
- **bcrypt** from `golang.org/x/crypto`
- **Pico CSS**, vendored; no frontend build step
The form for an assessment is defined once as data in
[`internal/befund/schema.go`](internal/befund/schema.go). The input form,
print view, comparison and import are all generated from it. A new field added
there appears everywhere.
## Running
With the container image:
```sh
cp .env.example .env
echo 'your-password' | podman run --rm -i localhost/physiobase hash-password # after the first build
# paste the hash into .env
podman compose up -d # or: docker compose up -d
```
`compose.yml` builds the image, mounts `./data` and publishes port 8080.
From source:
```sh
go build -o physiobase ./cmd/physiobase
echo 'your-password' | ./physiobase hash-password
PHYSIOBASE_USERNAME=admin PHYSIOBASE_PASSWORD_HASH='$2a$12$…' ./physiobase
```
Then open http://localhost:8080. An empty database gets a few sample entries
(tagged *Beispiel*) so the structure is visible. They can be deleted.
### Commands
| Command | Purpose |
|---|---|
| `physiobase` | Start the server |
| `physiobase hash-password` | Read a password from stdin, print its bcrypt hash |
| `physiobase healthcheck` | Exit 0 if `/healthz` answers; used by the container health check |
## Configuration
All configuration is through environment variables. Templates and static files
are embedded in the binary.
| Variable | Default | Purpose |
|---|---|---|
| `PHYSIOBASE_USERNAME` | — | **Required.** Login user |
| `PHYSIOBASE_PASSWORD_HASH` | — | **Required.** bcrypt hash of the password |
| `PHYSIOBASE_PORT` | `8080` | Listen port |
| `PHYSIOBASE_DATA_DIR` | `./data` | Root for everything below |
| `PHYSIOBASE_DB_PATH` | `<data>/physiobase.db` | SQLite database |
| `PHYSIOBASE_MEDIA_DIR` | `<data>/medien` | Uploaded images, videos, PDFs |
| `PHYSIOBASE_BACKUP_DIR` | `<data>/backup` | Daily database backups |
| `PHYSIOBASE_BACKUP_KEEP` | `14` | Backups to keep; `0` turns them off |
| `PHYSIOBASE_PRACTICE_NAME` | `PhysioBase` | Shown in the header and on printouts |
| `PHYSIOBASE_BASE_URL` | — | External URL; an `https://` value makes cookies `Secure` and enables HSTS |
| `PHYSIOBASE_SEED` | `1` | Insert sample entries into an empty database |
| `PHYSIOBASE_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` |
| `PHYSIOBASE_LOG_FORMAT` | `text` | `text` or `json`; logs go to stderr |
The server refuses to start without a username and password hash. There is no
mode without login.
## Security
- Sessions are signed cookies (expiry plus HMAC). Logging out rotates the
signing secret, which ends every session on every device. Changing the
password does the same.
- Logins are rate limited: at most two bcrypt checks run at once, and each
failed attempt waits a second.
- Strict Content Security Policy: no inline scripts or styles, and
`script-src 'self'`.
- Cross-site POSTs are rejected through Go's `http.CrossOriginProtection`.
- Uploaded files are only served if they belong to an entry in the database.
**Health data:** once real patient data is stored, run it behind HTTPS with
access limited to people who need it. Encryption at rest is not built in
yet; put the data directory on an encrypted volume.
## Data and backups
```
data/
├── physiobase.db SQLite (WAL mode)
├── medien/ uploads
└── backup/ physiobase-YYYY-MM-DD_HHMMSS.db
```
Do not copy `physiobase.db` while the server runs. In WAL mode, recent writes can
still be in `physiobase.db-wal`. Use the files in `backup/`; each one is a
complete, consistent database.
Restore: stop the server, copy a backup to `physiobase.db`, delete
`physiobase.db-wal` and `physiobase.db-shm`, start again.
## Development
```sh
go test -race ./...
golangci-lint run ./...
gofumpt -l -extra .
```
The same checks, plus `go mod tidy` and `govulncheck`, run in CI on every push
(see [`.hearthforge-ci.toml`](.hearthforge-ci.toml)).
▾Aassets.go
@@ -0,0 +1,11 @@
// Package physiobase bettet Templates und statische Dateien in das Binary ein,
// damit zur Laufzeit kein Quellbaum gebraucht wird.
package physiobase
import "embed"
//go:embed web/templates/*.html
var TemplatesFS embed.FS
//go:embed all:web/static
var StaticFS embed.FS
▾Acmd/physiobase/main.go
@@ -0,0 +1,158 @@
// physiobase – Wissensdatenbank für die Physiotherapie.
//
// physiobase Server starten (Konfiguration über PHYSIOBASE_*)
// physiobase hash-password bcrypt-Hash für PHYSIOBASE_PASSWORD_HASH (Passwort über stdin)
// physiobase healthcheck /healthz prüfen (für den Container-HEALTHCHECK)
package main
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"forge.gpl-nas.de/physiobase/internal/config"
"forge.gpl-nas.de/physiobase/internal/database"
"forge.gpl-nas.de/physiobase/internal/store"
"forge.gpl-nas.de/physiobase/internal/web"
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "hash-password":
os.Exit(hashPassword())
case "healthcheck":
os.Exit(healthcheck())
default:
fmt.Fprintf(os.Stderr, "unbekannter Befehl %q (hash-password, healthcheck)\n", os.Args[1])
os.Exit(2)
}
}
if err := run(); err != nil {
slog.Error("beendet mit fehler", "err", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
var h slog.Handler = slog.NewTextHandler(os.Stderr, opts)
if cfg.LogJSON {
h = slog.NewJSONHandler(os.Stderr, opts)
}
log := slog.New(h)
slog.SetDefault(log)
if err := os.MkdirAll(cfg.MediaDir, 0o750); err != nil {
return fmt.Errorf("medienordner: %w", err)
}
db, err := database.Open(cfg.DBPath)
if err != nil {
return err
}
defer func() {
if err := database.Close(db); err != nil {
log.Error("datenbank schließen", "err", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go database.RunBackups(ctx, db, cfg.BackupDir, cfg.BackupKeep, log)
st := store.New(db)
if cfg.Seed {
if seeded, err := st.Seed(ctx); err != nil {
return fmt.Errorf("beispieldaten: %w", err)
} else if seeded {
log.Info("beispieleinträge angelegt")
}
}
srv, err := web.New(ctx, cfg, st, log)
if err != nil {
return err
}
httpSrv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Info("physiobase gestartet", "port", cfg.Port, "db", cfg.DBPath, "medien", cfg.MediaDir)
errCh <- httpSrv.ListenAndServe()
}()
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-ctx.Done():
log.Info("fahre herunter")
shutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := httpSrv.Shutdown(shutCtx); err != nil {
return err
}
}
return nil
}
func hashPassword() int {
fmt.Fprint(os.Stderr, "Passwort: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
fmt.Fprintln(os.Stderr, "kein Passwort gelesen")
return 1
}
pw := strings.TrimRight(line, "\r\n")
if len(pw) < 8 {
fmt.Fprintln(os.Stderr, "Passwort zu kurz (mind. 8 Zeichen)")
return 1
}
h, err := web.HashPassword(pw)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Println(h)
return 0
}
func healthcheck() int {
port := os.Getenv("PHYSIOBASE_PORT")
if port == "" {
port = "8080"
}
c := http.Client{Timeout: 4 * time.Second}
if _, err := strconv.Atoi(port); err != nil {
return 1
}
// Fester Loopback-Aufruf, Port ist numerisch geprüft.
resp, err := c.Get("http://127.0.0.1:" + port + "/healthz") //nolint:gosec // G704: kein fremdes Ziel möglich
if err != nil {
return 1
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 1
}
return 0
}
▾Acompose.yml
@@ -0,0 +1,30 @@
services:
physiobase:
build:
context: .
dockerfile: Containerfile
image: localhost/physiobase:latest
container_name: physiobase
ports:
- "8080:8080"
volumes:
- ./data:/data
env_file:
- .env
environment:
- PHYSIOBASE_PORT=8080
- PHYSIOBASE_DATA_DIR=/data
- PHYSIOBASE_PRACTICE_NAME=PhysioBase
# Keep this many daily database backups in ./data/backup (0 = off)
- PHYSIOBASE_BACKUP_KEEP=14
# Uncomment when running behind an HTTPS proxy:
# - PHYSIOBASE_BASE_URL=https://physio.example.com
healthcheck:
test: ["CMD", "physiobase", "healthcheck"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
restart: unless-stopped
# Time for the WAL checkpoint on shutdown
stop_grace_period: 30s
▾Adocs/preview-befund.png
Bin 0 → 151528 bytes@ 967d8e2@ master
Binary file — not shown
▾Adocs/preview-wissen.png
Bin 0 → 152355 bytes@ 967d8e2@ master
Binary file — not shown
▾Ago.mod
@@ -0,0 +1,21 @@
module forge.gpl-nas.de/physiobase
go 1.26.0
require (
github.com/yuin/goldmark v1.8.7
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.62.0
)
require (
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.49.0 // indirect
modernc.org/libc v1.80.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
▾Ago.sum
@@ -0,0 +1,54 @@
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.8.7 h1:XT/tNqCIbabh0ln675w4x3dv04kQBKi8NwYqi9IJaxI=
github.com/yuin/goldmark v1.8.7/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b h1:WE5SLOURcNd8s6uPR/n4WV4weQxxYKi5OpEZkfNVvdE=
modernc.org/cc/v4 v4.30.6-0.20261101114202-1b9d1a076f6b/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.38.1 h1:wdxJVBuFT8JufZnnQyS6Bbz3NOWhViD0xJOGR96Bpd4=
modernc.org/ccgo/v4 v4.38.1/go.mod h1:kOpEkJRbFwBvjUWwn4j1j9B7iFHbl1+orhgTMCDkDBk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.80.1 h1:ejn7HtTV3GknNKbklqWQ1nvUrfX8mTxs1D+xp/JQwy4=
modernc.org/libc v1.80.1/go.mod h1:pvo25Rs3OJEbDqhUV9jdEGevxeLJrJeDmNid/AcQV1g=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.62.0 h1:gMaCjSM1qpl+iOjiWhR1LYP3zqrYDI+5GYLGt6veP2g=
modernc.org/sqlite v1.62.0/go.mod h1:Jzjh1mt4s0HuQ0KvtB1LIuHiGRmU2D5pgj8dUhW7/SE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
▾Ainternal/befund/befund_test.go
@@ -0,0 +1,105 @@
package befund
import (
"net/url"
"testing"
)
func TestDelta(t *testing.T) {
cases := []struct{ a, b, want string }{
{"0-0-120", "0-0-135", "+15"},
{"5-0-110", "0-0-130", "-5 / +20"},
{"3", "4", "+1"},
{"4", "4", "±0"},
{"12,5 s", "10 s", "-2,5"},
{"4+", "5", "+1"},
{"", "3", ""},
{"elastisch", "fest", ""},
}
for _, c := range cases {
if got := Delta(c.a, c.b); got != c.want {
t.Errorf("Delta(%q,%q) = %q, want %q", c.a, c.b, got, c.want)
}
}
}
func TestFromForm(t *testing.T) {
f := url.Values{
"f.diag": {" Gonarthrose re "}, "f.rf_cancer": {"1"}, "f.pain_now": {"6"}, "f.unbekannt": {"x"},
"t.rom.n": {"3"}, "t.rom.0.joint": {"Knie re"}, "t.rom.0.act": {"0-5-110"}, "t.rom.2.move": {"Ext"},
"stamps": {`[{"v":0,"x":0.5,"y":0.3,"s":"pain","c":"#ff0000","z":2},{"v":9,"x":0.5,"y":0.5,"s":"pain"},{"v":1,"x":2,"y":0,"s":"pain"},{"v":1,"x":0.1,"y":0.1,"s":"bogus"}]`},
}
d := FromForm(f)
if d.Get("diag") != "Gonarthrose re" || d.Get("rf_cancer") != "1" || d.Get("pain_now") != "6" {
t.Errorf("Felder: %+v", d.Fields)
}
if _, ok := d.Fields["unbekannt"]; ok {
t.Error("unbekanntes Feld übernommen")
}
if n := len(d.Tables["rom"]); n != 2 {
t.Errorf("ROM-Zeilen %d, erwartet 2 (leere Zeile verworfen)", n)
}
if len(d.Stamps) != 1 {
t.Errorf("Stempel %d, erwartet 1 gültigen", len(d.Stamps))
}
if !d.HasSection(Sections[1]) { // Screening
t.Error("Screening-Sektion sollte Daten haben")
}
}
// Datei im Format der alten Befundseite
const legacy = `{
"name": "Erika Muster", "diag": "Z. n. VKB-Plastik re", "date": "2025-10-01", "dob": "1990",
"S/I": "mittel", "24h": "morgens steif", "im_payr": true, "im_homann": false,
"custom-ab12cd": "Eigener Abschnitt",
"rom": [{"joint":"Knie re","move":"Flex/Ext","act":"0-5-100","pass":"0-0-110","end":"fest"}, {"joint":"","move":"","act":"","pass":"","end":""}],
"mfp": [{"muscle":"Quadrizeps","right":"4","left":"5"}],
"circ": [], "timeline": [{"date":"2025-08-01","title":"OP","note":"VKB"}],
"treat": [{"date":"2025-10-02","goal":"Extension","note":"gut"}, {"date":"","goal":"","note":""}],
"blocks": {}, "fields": {}, "order": [], "_v": 2
}`
func TestParseLegacy(t *testing.T) {
l, err := ParseLegacy([]byte(legacy))
if err != nil {
t.Fatal(err)
}
if l.Name != "Erika Muster" || l.Date != "2025-10-01" || l.Dob != "1990" {
t.Errorf("Kopf: %+v", l)
}
d := l.Data
if d.Get("diag") == "" || d.Get("S/I") != "mittel" || d.Get("24h") == "" || d.Get("im_payr") != "1" || d.Get("im_homann") != "" {
t.Errorf("Felder: %v", d.Fields)
}
if d.Get("zusatz") != "Eigener Abschnitt" {
t.Errorf("Zusatzabschnitt: %q", d.Get("zusatz"))
}
if len(d.Tables["rom"]) != 1 || len(d.Tables["mfp"]) != 1 || len(d.Tables["timeline"]) != 1 {
t.Errorf("Tabellen: %v", d.Tables)
}
if len(l.Sessions) != 1 {
t.Errorf("Behandlungen: %d", len(l.Sessions))
}
if _, err := ParseLegacy([]byte(`{"foo":1}`)); err == nil {
t.Error("fremde JSON-Datei akzeptiert")
}
}
func TestCompare(t *testing.T) {
a, b := New(), New()
a.Fields["pain_now"], b.Fields["pain_now"] = "7", "3"
a.Fields["diag"], b.Fields["diag"] = "X", "X"
a.Tables["rom"] = []Row{{"joint": "Knie", "move": "Flex", "act": "0-0-100"}}
b.Tables["rom"] = []Row{{"joint": "Knie", "move": "Flex", "act": "0-0-125"}, {"joint": "Hüfte", "move": "Flex", "act": "0-0-120"}}
b.Tables["psfs"] = []Row{{"activity": "Treppe", "score": "6"}}
fields, tables := Compare(a, b)
if len(fields) != 1 || fields[0].A != "7" || fields[0].B != "3" {
t.Errorf("Felder: %+v", fields)
}
if len(tables) != 2 || tables[0].Rows[0].Cells[0].Delta != "+25" || len(tables[0].Rows) != 2 {
t.Errorf("Tabellen: %+v", tables)
}
if PSFSMean(b) != "6,0" {
t.Errorf("PSFS: %q", PSFSMean(b))
}
}
▾Ainternal/befund/data.go
@@ -0,0 +1,458 @@
package befund
import (
"encoding/json"
"fmt"
"math"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
)
type Row map[string]string
// Stamp ist ein Symbol im Körperschema. X/Y sind relativ (0..1), damit die
// Position unabhängig von Bildschirm- und Druckgröße stimmt.
type Stamp struct {
V int `json:"v"` // Ansicht (Index in Views)
X float64 `json:"x"`
Y float64 `json:"y"`
S string `json:"s"` // Symbol-Key
C string `json:"c"` // Farbe #rrggbb
Z int `json:"z"` // Größe 1..3
}
type Data struct {
Fields map[string]string `json:"fields"`
Tables map[string][]Row `json:"tables"`
Stamps []Stamp `json:"stamps"`
}
func New() Data { return Data{Fields: map[string]string{}, Tables: map[string][]Row{}} }
func Parse(raw string) (Data, error) {
d := New()
if strings.TrimSpace(raw) == "" {
return d, nil
}
if err := json.Unmarshal([]byte(raw), &d); err != nil {
return New(), fmt.Errorf("befund-daten: %w", err)
}
if d.Fields == nil {
d.Fields = map[string]string{}
}
if d.Tables == nil {
d.Tables = map[string][]Row{}
}
return d, nil
}
func (d Data) JSON() string {
b, _ := json.Marshal(d)
return string(b)
}
func (d Data) Get(k string) string { return d.Fields[k] }
// HasSection: enthält die Sektion irgendeinen Wert? (Fachbereiche aufklappen, Druck filtern)
func (d Data) HasSection(s Section) bool {
for _, p := range s.Parts {
if d.HasPart(p) {
return true
}
}
return false
}
func (d Data) HasPart(p Part) bool {
for _, f := range p.Fields {
if strings.TrimSpace(d.Fields[f.Key]) != "" {
return true
}
}
if p.Table != nil && len(d.Tables[p.Table.Key]) > 0 {
return true
}
return p.Body && len(d.Stamps) > 0
}
// ---------- Formular → Daten ----------
var colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// FromForm liest das Befundformular. Tabellenzeilen heißen t.<tabelle>.<i>.<spalte>,
// komplett leere Zeilen werden verworfen.
func FromForm(form url.Values) Data {
d := New()
for k, f := range fieldIndex {
v := strings.TrimSpace(strings.ReplaceAll(form.Get("f."+k), "\r\n", "\n"))
if f.Type == "check" {
if v != "" {
d.Fields[k] = "1"
}
continue
}
if v != "" {
d.Fields[k] = v
}
}
for _, t := range Tables {
n, _ := strconv.Atoi(form.Get("t." + t.Key + ".n"))
n = min(n, 200)
for i := range n {
row := Row{}
for _, c := range t.Cols {
if v := strings.TrimSpace(form.Get(fmt.Sprintf("t.%s.%d.%s", t.Key, i, c.Key))); v != "" {
row[c.Key] = v
}
}
if len(row) > 0 {
d.Tables[t.Key] = append(d.Tables[t.Key], row)
}
}
}
var stamps []Stamp
if json.Unmarshal([]byte(form.Get("stamps")), &stamps) == nil {
d.Stamps = CleanStamps(stamps)
}
return d
}
func CleanStamps(in []Stamp) []Stamp {
known := map[string]bool{}
for _, s := range Symbols {
known[s.Key] = true
}
var out []Stamp
for _, s := range in {
if s.V < 0 || s.V >= len(Views) || !known[s.S] || math.IsNaN(s.X) || math.IsNaN(s.Y) ||
s.X < 0 || s.X > 1 || s.Y < 0 || s.Y > 1 {
continue
}
if !colorRe.MatchString(s.C) {
s.C = "#d32f2f"
}
s.Z = max(1, min(3, s.Z))
out = append(out, s)
if len(out) == 500 {
break
}
}
return out
}
// ---------- Darstellung ----------
// TableRows liefert die Zeilen für das Formular plus leere Zeilen zum Ausfüllen.
func (d Data) TableRows(t *Table, blank int) []Row {
rows := append([]Row{}, d.Tables[t.Key]...)
for range blank {
rows = append(rows, Row{})
}
return rows
}
type StampView struct {
X, Y, Size float64
Char, Color, Name string
}
func symbol(key string) Symbol {
for _, s := range Symbols {
if s.Key == key {
return s
}
}
return Symbols[0]
}
// StampSize in viewBox-Einheiten (Bilder sind ca. 80 px breit).
func StampSize(z int) float64 { return []float64{0, 7, 11, 16}[max(1, min(3, z))] }
func (d Data) StampsFor(view int) []StampView {
v := Views[view]
var out []StampView
for _, s := range d.Stamps {
if s.V != view {
continue
}
sym := symbol(s.S)
out = append(out, StampView{X: s.X * float64(v.W), Y: s.Y * float64(v.H), Size: StampSize(s.Z), Char: sym.Char, Color: s.C, Name: sym.Name})
}
return out
}
// UsedSymbols für die Legende im Druck.
func (d Data) UsedSymbols() []Symbol {
used := map[string]bool{}
for _, s := range d.Stamps {
used[s.S] = true
}
var out []Symbol
for _, s := range Symbols {
if used[s.Key] {
out = append(out, s)
}
}
return out
}
// ---------- Import der alten Befundseite ----------
// Legacy ist das Exportformat der früheren Befundseite (Befund_*.json).
type Legacy struct {
Name, Date, Dob string
Data Data
Sessions []Row // date, goal, note
}
// ParseLegacy erkennt alte Exporte an den flachen data-bind-Schlüsseln.
func ParseLegacy(b []byte) (Legacy, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return Legacy{}, fmt.Errorf("kein gültiges JSON: %w", err)
}
if _, ok := raw["name"]; !ok {
if _, ok := raw["rom"]; !ok {
return Legacy{}, fmt.Errorf("keine Befund-Datei der alten Befundseite")
}
}
str := func(k string) string {
var s string
if json.Unmarshal(raw[k], &s) == nil {
return strings.TrimSpace(s)
}
var bl bool
if json.Unmarshal(raw[k], &bl) == nil && bl {
return "1"
}
return ""
}
l := Legacy{Name: str("name"), Date: str("date"), Dob: str("dob"), Data: New()}
for k := range fieldIndex {
if v := str(k); v != "" {
l.Data.Fields[k] = v
}
}
// Eigene Zusatzabschnitte hießen custom-xxxx (Überschrift wurde nie gespeichert)
var extra []string
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if strings.HasPrefix(k, "custom-") {
if v := str(k); v != "" {
extra = append(extra, v)
}
}
}
if len(extra) > 0 {
l.Data.Fields["zusatz"] = strings.TrimSpace(l.Data.Fields["zusatz"] + "\n\n" + strings.Join(extra, "\n\n"))
}
rowsOf := func(k string) []Row {
var in []map[string]any
if err := json.Unmarshal(raw[k], &in); err != nil {
return nil // Feld fehlt oder hat ein anderes Format
}
var out []Row
for _, m := range in {
r := Row{}
for ck, cv := range m {
if s := strings.TrimSpace(fmt.Sprint(cv)); s != "" && cv != nil {
r[ck] = s
}
}
if len(r) > 0 {
out = append(out, r)
}
}
return out
}
for _, t := range Tables {
if rs := rowsOf(t.Key); len(rs) > 0 {
l.Data.Tables[t.Key] = rs
}
}
l.Sessions = rowsOf("treat")
return l, nil
}
// ---------- Vergleich zweier Befunde ----------
type FieldDiff struct{ Section, Label, A, B string }
type RowDiff struct {
Key string
Cells []CellDiff
}
type CellDiff struct{ Label, A, B, Delta string }
type TableDiff struct {
Title string
Rows []RowDiff
}
func Compare(a, b Data) ([]FieldDiff, []TableDiff) {
var fields []FieldDiff
for _, s := range Sections {
for _, p := range s.Parts {
for _, f := range p.Fields {
va, vb := a.Fields[f.Key], b.Fields[f.Key]
if va == vb {
continue
}
label := f.Label
if label == "" {
label = s.Title
if p.Title != "" {
label = p.Title
}
}
if f.Type == "check" {
va, vb = yesNo(va), yesNo(vb)
}
fields = append(fields, FieldDiff{Section: s.Title, Label: label, A: va, B: vb})
}
}
}
titles := map[string]string{
"rom": "ROM", "mfp": "MFP", "circ": "Umfang", "tests": "Spezifische Tests",
"measures": "Assessments & PROMs", "psfs": "PSFS",
}
var tables []TableDiff
for _, t := range Tables {
if len(t.Match) == 0 {
continue
}
td := TableDiff{Title: titles[t.Key]}
key := func(r Row) string {
parts := make([]string, len(t.Match))
for i, m := range t.Match {
parts[i] = r[m]
}
return strings.Join(parts, " · ")
}
ra, rb := map[string]Row{}, map[string]Row{}
var order []string
for _, r := range a.Tables[t.Key] {
k := key(r)
if _, seen := ra[k]; !seen {
order = append(order, k)
}
ra[k] = r
}
for _, r := range b.Tables[t.Key] {
k := key(r)
if _, seenA := ra[k]; !seenA {
if _, seenB := rb[k]; !seenB {
order = append(order, k)
}
}
rb[k] = r
}
for _, k := range order {
rd := RowDiff{Key: k}
for _, c := range t.Cols {
if !contains(t.Diff, c.Key) {
continue
}
va, vb := ra[k][c.Key], rb[k][c.Key]
if va == "" && vb == "" {
continue
}
rd.Cells = append(rd.Cells, CellDiff{Label: c.Label, A: va, B: vb, Delta: Delta(va, vb)})
}
if len(rd.Cells) > 0 {
td.Rows = append(td.Rows, rd)
}
}
if len(td.Rows) > 0 {
tables = append(tables, td)
}
}
return fields, tables
}
var numRe = regexp.MustCompile(`-?\d+(?:[.,]\d+)?`)
// Delta vergleicht Zahlen positionsweise: "0-0-120" → "0-0-135" ergibt "+15"
// (nur geänderte Positionen), "3" → "4" ergibt "+1".
func Delta(a, b string) string {
// Neutral-Null-Notation: Bindestriche sind Trenner, keine Vorzeichen
norm := func(s string) []float64 {
s = strings.ReplaceAll(s, "–", "-")
var out []float64
for _, m := range numRe.FindAllString(strings.ReplaceAll(s, "-", " "), -1) {
f, err := strconv.ParseFloat(strings.ReplaceAll(m, ",", "."), 64)
if err == nil {
out = append(out, f)
}
}
return out
}
na, nb := norm(a), norm(b)
if len(na) == 0 || len(na) != len(nb) {
return ""
}
var parts []string
for i := range na {
d := nb[i] - na[i]
if d == 0 {
continue
}
s := strconv.FormatFloat(d, 'f', -1, 64)
if d > 0 {
s = "+" + s
}
parts = append(parts, strings.ReplaceAll(s, ".", ","))
}
if len(parts) == 0 {
return "±0"
}
return strings.Join(parts, " / ")
}
func yesNo(v string) string {
if v != "" {
return "ja"
}
return "nein"
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// PSFSMean: Mittelwert der PSFS-Aktivitäten (leer, wenn keine Werte).
func PSFSMean(d Data) string {
var sum float64
var n int
for _, r := range d.Tables["psfs"] {
if f, err := strconv.ParseFloat(strings.ReplaceAll(r["score"], ",", "."), 64); err == nil {
sum += f
n++
}
}
if n == 0 {
return ""
}
return strings.ReplaceAll(strconv.FormatFloat(sum/float64(n), 'f', 1, 64), ".", ",")
}
func (d Data) StampsJSON() string {
if d.Stamps == nil {
return "[]"
}
b, _ := json.Marshal(d.Stamps)
return string(b)
}
▾Ainternal/befund/schema.go
@@ -0,0 +1,333 @@
// Package befund beschreibt das Befundformular als Daten. Formular, Druckansicht,
// Vergleich und Import werden alle aus diesem einen Schema erzeugt.
//
// Die Feldschlüssel entsprechen der früheren Befundseite (data-bind), damit alte
// JSON-Exporte 1:1 importiert werden können.
package befund
type Field struct {
Key, Label, Placeholder string
Type string // "text" (Standard), "area", "select", "check", "nrs" (0–10)
Rows int
Options []string
Wide bool // volle Breite im Raster
}
type Col struct {
Key, Label, Placeholder string
Type string // "text", "number", "date", "select"
Options []string
List string // id einer <datalist> mit Vorschlägen
}
type Table struct {
Key string
Cols []Col
Match []string // Spalten, über die Zeilen zweier Befunde zugeordnet werden
Diff []string // Spalten, deren Werte verglichen werden
}
// Part ist ein Abschnitt innerhalb einer Sektion: Feldraster, Tabelle oder Körperschema.
type Part struct {
Title string
Collapsible bool
Fields []Field
Table *Table
Body bool
}
type Section struct {
ID, Title string
Specialty bool // Fachbereich: eingeklappt, außer er enthält Daten
Parts []Part
}
func area(key, label string) Field { return Field{Key: key, Label: label, Type: "area"} }
func text(key, label string) Field { return Field{Key: key, Label: label} }
func wide(f Field) Field { f.Wide = true; return f }
func ph(f Field, p string) Field { f.Placeholder = p; return f }
func rows(f Field, n int) Field { f.Rows = n; return f }
func check(key, label string) Field { return Field{Key: key, Label: label, Type: "check"} }
func nrs(key, label string) Field { return Field{Key: key, Label: label, Type: "nrs"} }
var (
TableTimeline = &Table{Key: "timeline", Cols: []Col{
{Key: "date", Label: "Datum", Type: "date"},
{Key: "title", Label: "Ereignis"},
{Key: "note", Label: "Kurzbeschreibung"},
}}
TableROM = &Table{Key: "rom", Match: []string{"joint", "move"}, Diff: []string{"act", "pass", "end"}, Cols: []Col{
{Key: "joint", Label: "Gelenk", Placeholder: "z. B. Knie re"},
{Key: "move", Label: "Bewegung", Placeholder: "Flex/Ext"},
{Key: "act", Label: "Aktiv", Placeholder: "0-0-130"},
{Key: "pass", Label: "Passiv"},
{Key: "end", Label: "Endgefühl", Placeholder: "z. B. elastisch"},
}}
TableMFP = &Table{Key: "mfp", Match: []string{"muscle"}, Diff: []string{"right", "left"}, Cols: []Col{
{Key: "muscle", Label: "Muskel / Gruppe", Placeholder: "z. B. Quadrizeps"},
{Key: "right", Label: "rechts (0–5)", Placeholder: "z. B. 4+"},
{Key: "left", Label: "links (0–5)", Placeholder: "z. B. 4"},
}}
TableCirc = &Table{Key: "circ", Match: []string{"point"}, Diff: []string{"right", "left"}, Cols: []Col{
{Key: "point", Label: "Messpunkt", Placeholder: "z. B. 10 cm unter Patella"},
{Key: "right", Label: "rechts (cm)"},
{Key: "left", Label: "links (cm)"},
}}
TableTests = &Table{Key: "tests", Match: []string{"test", "side"}, Diff: []string{"result"}, Cols: []Col{
{Key: "test", Label: "Test", Placeholder: "z. B. Lachman", List: "dl-tests"},
{Key: "side", Label: "Seite", Type: "select", Options: []string{"", "re", "li", "bds", "–"}},
{Key: "result", Label: "Ergebnis", Type: "select", Options: []string{"", "positiv", "negativ", "fraglich"}},
{Key: "note", Label: "Bemerkung", Placeholder: "z. B. Schmerz reproduziert, Endgefühl"},
}}
TableMeasures = &Table{Key: "measures", Match: []string{"tool"}, Diff: []string{"value"}, Cols: []Col{
{Key: "tool", Label: "Instrument", Placeholder: "z. B. ODI, TUG", List: "dl-measures"},
{Key: "value", Label: "Wert", Placeholder: "z. B. 34"},
{Key: "unit", Label: "Einheit / Max.", Placeholder: "z. B. % · s · /80"},
{Key: "note", Label: "Bemerkung"},
}}
TablePSFS = &Table{Key: "psfs", Match: []string{"activity"}, Diff: []string{"score"}, Cols: []Col{
{Key: "activity", Label: "Aktivität (vom Patienten genannt)", Placeholder: "z. B. Treppe steigen 2 Stockwerke"},
{Key: "score", Label: "0–10", Type: "number"},
}}
Tables = []*Table{TableTimeline, TableROM, TableMFP, TableCirc, TableTests, TableMeasures, TablePSFS}
// Vorschläge für Instrumente (nur Namen; Werte trägt man selbst ein)
Measures = []string{
"NRS Schmerz", "PSFS (Mittelwert)", "ODI", "RMDQ", "NDI", "KOOS", "HOOS", "LEFS", "QuickDASH", "SPADI",
"VISA-A", "VISA-P", "FAAM", "TSK", "PCS", "STarT Back", "Örebro (Kurzform)", "EQ-5D",
"TUG", "30s Chair Stand", "5x Sit-to-Stand", "6MWT", "10m Gehtest", "BBS", "Tinetti", "Einbeinstand",
"Handkraft (Dynamometer)", "Y-Balance", "Single Leg Hop", "Borg (RPE)",
}
)
var Sections = []Section{
{ID: "patient", Title: "Patientendaten", Parts: []Part{{Fields: []Field{
text("diag", "Ärztl. Diagnose"),
text("onset", "Datum der Schädigung/Erkrankung/OP"),
area("coDiag", "Nebendiagnosen"),
area("meds", "Medikamente und Dosierung"),
area("contra", "Kontraindikationen"),
area("loadLimit", "Belastbarkeit (ärztl. Vorgaben/Grenze)"),
area("nursing", "Hinweise aus der Pflege"),
area("tools", "Hilfsmittel"),
wide(area("firstHyp", "Erste Hypothese/erwartbare Probleme")),
}}}},
{ID: "screening", Title: "Screening: Red & Yellow Flags", Parts: []Part{
{Title: "Red Flags (Hinweise auf ernste Pathologie)", Fields: []Field{
check("rf_cancer", "Krebserkrankung in der Vorgeschichte"),
check("rf_weight", "Ungewollter Gewichtsverlust"),
check("rf_fever", "Fieber, Infektzeichen, Immunsuppression"),
check("rf_night", "Ruhe-/Nachtschmerz ohne Bewegungsbezug"),
check("rf_trauma", "Relevantes Trauma / Osteoporose, Kortison (Fraktur?)"),
check("rf_cauda", "Reithosenanästhesie, Blasen-/Mastdarmstörung"),
check("rf_neuro", "Progrediente neurologische Ausfälle"),
check("rf_vasc", "Hinweise auf TVT/LE, kardiovaskuläre Ursache"),
check("rf_unwell", "Allgemeines Krankheitsgefühl, systemische Symptome"),
wide(ph(area("rf_action", "Bewertung & Vorgehen"), "z. B. keine Red Flags · Arzt am … informiert · Behandlung nur nach Abklärung")),
}},
{Title: "Yellow / Blue Flags (Chronifizierungsrisiko)", Fields: []Field{
check("yf_fear", "Angst-Vermeidungsverhalten"),
check("yf_cata", "Katastrophisieren"),
check("yf_mood", "Niedergeschlagenheit, Angst, Stress"),
check("yf_expect", "Geringe Erwartung an Besserung, passive Bewältigung"),
check("yf_work", "Arbeitsplatzprobleme, Konflikte, fehlende Unterstützung (Blue Flags)"),
wide(ph(text("screen_tool", "Screening-Instrument & Ergebnis"), "z. B. STarT Back: mittleres Risiko · Örebro-Kurzform: 52")),
}},
}},
{ID: "anamnese", Title: "Anamnese", Parts: []Part{
{Title: "Körperschema", Body: true},
{Title: "Schmerz", Fields: []Field{
nrs("pain_now", "NRS aktuell (0–10)"),
nrs("pain_best", "NRS beste 24 h"),
nrs("pain_worst", "NRS schlimmste 24 h"),
{
Key: "pain_type", Label: "Dominanter Schmerzmechanismus", Type: "select",
Options: []string{"", "nozizeptiv", "neuropathisch", "noziplastisch", "gemischt", "unklar"},
},
}},
{Fields: []Field{
wide(rows(ph(area("anaIntro", "Einleitende Frage(n)"), "Was führt Sie zu mir? …"), 5)),
}},
{Title: "Zeitlicher Verlauf", Table: TableTimeline},
{Fields: []Field{
area("24h", "24h-Verlauf"),
area("provo", "Provokation"),
text("lind", "Linderung"),
text("S/I", "Schwere/Irritierbarkeit"),
area("nas", "NAS nach Situation, Qualität etc."),
area("anaLoad", "Alltägliche Belastung (Beruf, Sport, Hobby, Tätigkeit)"),
area("envPos", "Umweltfaktoren – positiv"),
area("envNeg", "Umweltfaktoren – negativ"),
}},
{Title: "Allgemeiner Eindruck, Orientierung, Psyche, Kognition, Adhärenz", Collapsible: true, Fields: []Field{
text("psyche", "Psyche"),
text("cognition", "Kognition"),
wide(area("adh", "Compliance/Adhärenz/Sonstiges")),
}},
}},
{ID: "inspektion", Title: "Inspektion", Parts: []Part{{Fields: []Field{
wide(area("inspection", "Inspektionsbefund (Haltung, Haut, Ödeme, Narben, Atmung etc.)")),
}}}},
{ID: "bps", Title: "Psychosoziale Anamnese", Parts: []Part{{Fields: []Field{
area("bps_family", "Familie/soziales Netz"),
area("bps_home", "Wohn-/Lebenssituation"),
area("bps_work", "Beruf/Arbeitsbelastung"),
area("bps_leisure", "Freizeit/Aktivität"),
ph(area("bps_activity", "Körperliche Aktivität"), "Art, Min./Woche moderat/intensiv, Krafttraining?"),
area("bps_sleep", "Schlaf"),
wide(ph(area("bps_expect", "Vorstellungen & Erwartungen des Patienten"), "Eigene Erklärung der Beschwerden, erwarteter Verlauf, Wunsch an die Behandlung")),
}}}},
{ID: "hyp-anam", Title: "Hypothese nach Anamnese", Parts: []Part{{Fields: []Field{
wide(rows(area("hypAfterAnam", ""), 4)),
}}}},
{ID: "exam", Title: "Körperliche Untersuchung – Basis", Parts: []Part{
{Title: "ROM (Neutral-Null)", Table: TableROM},
{Title: "MFP (Muskelkraftprüfung)", Fields: []Field{wide(ph(text("mfp-allgemein", "Allgemein"), "z. B. Seitenvergleich, Ermüdung"))}, Table: TableMFP},
{Title: "Umfangmessung", Collapsible: true, Table: TableCirc},
{Title: "Spezifische Tests", Table: TableTests},
{Title: "Assessments & PROMs (Messwerte für den Verlauf)", Table: TableMeasures},
{Title: "Ganganalyse", Collapsible: true, Fields: []Field{wide(ph(area("gait", ""), "Gangphase, Spurbreite, Armpendel, Ausweichmechanismen …"))}},
{Title: "Assessments – Notizen", Collapsible: true, Fields: []Field{wide(ph(area("assess", ""), "Durchführung, Auffälligkeiten (Werte bitte in die Tabelle oben)"))}},
{Title: "Sensibilität/Wahrnehmung", Collapsible: true, Fields: []Field{wide(ph(area("sens", ""), "Reflextests, Dermatome …"))}},
{Title: "Neurologische Untersuchung", Collapsible: true, Fields: []Field{
area("neuro_derm", "Sensibilität nach Dermatomen"),
area("neuro_myo", "Kennmuskeln/Myotome"),
area("neuro_reflex", "Reflexe (MER, Babinski)"),
area("neuro_dyn", "Neurodynamik (SLR, Slump, ULNT, PKB)"),
}},
{Title: "Sicherheitstests", Collapsible: true, Fields: []Field{wide(ph(area("safetyNeuro", ""), "z. B. Instabilitätstests HWS, vaskuläre Tests"))}},
}},
// ---- Fachbereiche ----
{ID: "ortho", Title: "Chirurgie/Orthopädie", Specialty: true, Parts: []Part{{Fields: []Field{
area("or_jointProv", "Gelenktest/Provokation"),
area("or_len", "Muskellänge – Details"),
}}}},
{ID: "gyn-geb", Title: "Gynäkologie – Geburtshilfe", Specialty: true, Parts: []Part{
{Fields: []Field{
text("gg_gravida", "Schwangerschaften (Gravida)"),
text("gg_para", "Geburten (Para)"),
area("gg_pregCourse", "Schwangerschaftsverlauf"),
area("gg_pregFindings", "Auffälligkeiten in der Schwangerschaft"),
text("gg_mode", "Geburtsmodus"),
area("gg_birthCourse", "Geburtsverlauf"),
text("gg_position", "Gebärposition"),
area("gg_comp", "Komplikationen"),
}},
{Title: "Angaben zum Kind", Collapsible: true, Fields: []Field{
text("gg_apgar", "APGAR"),
text("gg_child", "Name/Geschlecht"),
text("gg_stats", "Größe/Gewicht/KU"),
}},
{Fields: []Field{
area("gg_pelvic", "Beckenbodenbefund"),
area("gg_abdomen", "Bauchbefund"),
}},
}},
{ID: "gyn-uro", Title: "Gynäkologie/Urologie", Specialty: true, Parts: []Part{{Fields: []Field{
area("gu_pelvic", "Beckenbodenbefund"),
area("gu_abdomen", "Bauchbefund"),
}}}},
{ID: "innere", Title: "Innere Medizin", Specialty: true, Parts: []Part{
{Fields: []Field{
text("im_pulse_point", "Puls (Messpunkt)"),
ph(text("im_pulse_freqs", "Frequenz Ruhe/Belastung/nachher"), "z. B. 68 / 102 / 80"),
{Key: "im_pulse_rhythm", Label: "Pulsrhythmus", Type: "select", Options: []string{"", "regelmäßig", "unregelmäßig"}},
text("im_pulse_fill", "Pulsfüllung"),
ph(text("im_rr", "RR (Ruhe/Belastung/nachher)"), "z. B. 120/70 / …"),
text("im_meds", "Beeinflussende Medikamente"),
wide(area("im_breath", "Atemdiagramm (CSV/CSL/CAL/CAV | ARL/MI/ME)")),
}},
{Title: "Thrombosedruckpunkte (positiv)", Fields: []Field{
check("im_payr", "Payr"), check("im_homann", "Homans"), check("im_adduct", "Adduktoren"),
check("im_meyer", "Meyer"), check("im_bisgard", "Bisgaard"),
}},
}},
{ID: "neuro", Title: "Neurologie", Specialty: true, Parts: []Part{{Fields: []Field{
wide(area("ne_tone", "Muskeltonus (Spastik, Klonus, Rigor, Tremor, Akinese, FoG, Ataxie)")),
area("ne_sens", "Sensibilität (Seitenvergleich)"),
area("ne_prop", "Propriozeption (UEX/OEX)"),
area("ne_balance", "Romberg/Einbeinstand"),
area("ne_reflex", "Holding/Placing/Mirroring"),
}}}},
{ID: "paedo", Title: "Pädiatrie", Specialty: true, Parts: []Part{{Fields: []Field{
area("pa_breath", "Atmung (Sekret, Husten, Atemgeräusche, Atelektasen …)"),
area("pa_vitals", "Vitalfunktionen (Herzfrequenz, Puls, RR)"),
area("pa_tone", "Muskeltonus"),
area("pa_fine", "Feinmotorik"),
text("pa_gmfcs", "GMFCS"),
text("pa_mac", "MACS"),
area("pa_adl", "ADL/Selbstständigkeit"),
area("pa_move", "Bewegungsanalyse"),
area("pa_play", "Situations-/Spielverhalten"),
area("pa_assess", "Assessments"),
}}}},
// ---- Auswertung ----
{ID: "hyp-exam", Title: "Hypothese nach körperlicher Untersuchung", Parts: []Part{{Fields: []Field{
wide(rows(ph(area("hypAfterExam", "Arbeitshypothese"), "Was spricht dafür (Anamnese, Tests, Messwerte)?"), 4)),
wide(ph(area("ddx", "Differenzialdiagnosen"), "Alternative Erklärungen – und was dagegen spricht")),
}}}},
{ID: "icf", Title: "ICF – Hauptproblem und Problemanalyse", Parts: []Part{{Fields: []Field{
area("icf_part", "Partizipationsebene"),
area("icf_activity", "Aktivitätsebene"),
wide(area("icf_body", "Funktions- und Strukturebene")),
area("goal_patient", "Patienten-/Angehörigenziel"),
area("goal_pt", "PT-Ziel (SMART)"),
wide(area("goal_agreement", "Zielvereinbarung")),
}}, {Title: "PSFS – Patientenspezifische Funktionsskala (0 = nicht möglich, 10 = wie vor dem Problem)", Table: TablePSFS}}},
{ID: "prognosis", Title: "Prognose mit Begründung", Parts: []Part{{Fields: []Field{
wide(rows(area("prognosis", ""), 4)),
}}}},
{ID: "plan", Title: "Therapieplan", Parts: []Part{{Fields: []Field{
wide(rows(area("plan", "Plan (Methoden, Frequenz, Edukation, Heimprogramm, Hilfsmittel)"), 5)),
wide(ph(area("plan_evidence", "Begründung / Evidenz"), "Leitlinie, Studienlage, Protokoll aus der Wissensdatenbank")),
ph(text("plan_reassess", "Re-Assessment geplant"), "z. B. nach 6 Behandlungen: NRS, PSFS, ODI"),
check("consent", "Aufklärung über Befund, Prognose und Plan erfolgt; Einwilligung liegt vor"),
}}}},
{ID: "zusatz", Title: "Zusätzliche Notizen", Parts: []Part{{Fields: []Field{
wide(rows(area("zusatz", ""), 4)),
}}}},
}
// Symbole für das Körperschema (wie auf der alten Befundseite).
type Symbol struct{ Key, Char, Name string }
var Symbols = []Symbol{
{"pain", "⚡\ufe0e", "Schmerz"},
{"swelling", "(( ))", "Schwellung"},
{"fracture", "◆", "Fraktur"},
{"tender", "×", "Druckschmerz"},
{"lesion", "●", "Läsion"},
{"radiating", "→", "Ausstrahlung"},
{"sensory", "≈", "Sensibilitätsstörung"},
}
// Ansichten des Körperschemas; W/H = Bildgröße, dient als SVG-viewBox.
type View struct {
Name, Image string
W, H int
}
var Views = []View{
{"Vorne", "/static/body/body-front.png", 78, 180},
{"Hinten", "/static/body/body-back.png", 84, 176},
{"Seitlich", "/static/body/body-side.png", 106, 181},
}
var fieldIndex = func() map[string]Field {
m := map[string]Field{}
for _, s := range Sections {
for _, p := range s.Parts {
for _, f := range p.Fields {
if _, dup := m[f.Key]; dup {
panic("doppelter Feldschlüssel: " + f.Key)
}
m[f.Key] = f
}
}
}
return m
}()
func FieldByKey(k string) (Field, bool) { f, ok := fieldIndex[k]; return f, ok }
▾Ainternal/config/config.go
@@ -0,0 +1,81 @@
// Package config liest die gesamte Konfiguration aus Umgebungsvariablen.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Port int
DataDir string
DBPath string
MediaDir string
BackupDir string
BackupKeep int
Username string
PasswordHash string
PracticeName string
BaseURL string
Seed bool
LogLevel slog.Level
LogJSON bool
}
func (c *Config) IsHTTPS() bool { return strings.HasPrefix(c.BaseURL, "https://") }
func get(k, def string) string {
if v, ok := os.LookupEnv("PHYSIOBASE_" + k); ok && v != "" {
return v
}
return def
}
// Load liest die Konfiguration. Ohne Benutzername und Passwort-Hash startet die
// App nicht – es gibt keinen Modus ohne Anmeldung.
func Load() (*Config, error) {
c := &Config{
DataDir: get("DATA_DIR", "./data"),
Username: get("USERNAME", ""),
PasswordHash: get("PASSWORD_HASH", ""),
PracticeName: get("PRACTICE_NAME", "PhysioBase"),
BaseURL: strings.TrimRight(get("BASE_URL", ""), "/"),
}
c.DBPath = get("DB_PATH", filepath.Join(c.DataDir, "physiobase.db"))
c.MediaDir = get("MEDIA_DIR", filepath.Join(c.DataDir, "medien"))
c.BackupDir = get("BACKUP_DIR", filepath.Join(c.DataDir, "backup"))
var err error
if c.Port, err = strconv.Atoi(get("PORT", "8080")); err != nil || c.Port < 1 || c.Port > 65535 {
return nil, fmt.Errorf("PHYSIOBASE_PORT ungültig: %q", get("PORT", ""))
}
if c.BackupKeep, err = strconv.Atoi(get("BACKUP_KEEP", "14")); err != nil {
return nil, fmt.Errorf("PHYSIOBASE_BACKUP_KEEP ungültig: %w", err)
}
if c.Seed, err = strconv.ParseBool(get("SEED", "1")); err != nil {
return nil, fmt.Errorf("PHYSIOBASE_SEED ungültig: %w", err)
}
if err := c.LogLevel.UnmarshalText([]byte(get("LOG_LEVEL", "info"))); err != nil {
return nil, fmt.Errorf("PHYSIOBASE_LOG_LEVEL ungültig: %w", err)
}
switch f := get("LOG_FORMAT", "text"); f {
case "text":
case "json":
c.LogJSON = true
default:
return nil, fmt.Errorf("PHYSIOBASE_LOG_FORMAT muss text oder json sein, nicht %q", f)
}
if c.Username == "" || c.PasswordHash == "" {
return nil, errors.New("PHYSIOBASE_USERNAME und PHYSIOBASE_PASSWORD_HASH sind Pflicht (Hash erzeugen: physiobase hash-password)")
}
if !strings.HasPrefix(c.PasswordHash, "$2") {
return nil, errors.New("PHYSIOBASE_PASSWORD_HASH ist kein bcrypt-Hash")
}
return c, nil
}
▾Ainternal/config/config_test.go
@@ -0,0 +1,25 @@
package config
import "testing"
func TestLoadRequiresCredentials(t *testing.T) {
t.Setenv("PHYSIOBASE_USERNAME", "")
t.Setenv("PHYSIOBASE_PASSWORD_HASH", "")
if _, err := Load(); err == nil {
t.Fatal("Start ohne Zugangsdaten darf nicht gehen")
}
t.Setenv("PHYSIOBASE_USERNAME", "admin")
t.Setenv("PHYSIOBASE_PASSWORD_HASH", "klartext")
if _, err := Load(); err == nil {
t.Fatal("Klartext-Passwort darf nicht akzeptiert werden")
}
t.Setenv("PHYSIOBASE_PASSWORD_HASH", "$2a$12$abcdefghijklmnopqrstuv")
t.Setenv("PHYSIOBASE_DATA_DIR", "/x")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.DBPath != "/x/physiobase.db" || c.MediaDir != "/x/medien" || c.Port != 8080 {
t.Errorf("Defaults falsch: %+v", c)
}
}
▾Ainternal/database/backup.go
@@ -0,0 +1,75 @@
package database
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Backup schreibt eine konsistente Kopie der laufenden Datenbank (VACUUM INTO).
// Ein einfaches cp der .db-Datei reicht im WAL-Modus NICHT – Daten liegen ggf. noch in -wal.
func Backup(ctx context.Context, db *sql.DB, dir string) (string, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
name := filepath.Join(dir, "physiobase-"+time.Now().Format("2006-01-02_150405")+".db")
if _, err := db.ExecContext(ctx, `VACUUM INTO ?`, name); err != nil {
os.Remove(name)
return "", fmt.Errorf("backup: %w", err)
}
if err := os.Chmod(name, 0o600); err != nil {
return name, fmt.Errorf("backup rechte: %w", err)
}
return name, nil
}
// Prune behält die neuesten keep Backups.
func Prune(dir string, keep int) error {
files, err := filepath.Glob(filepath.Join(dir, "physiobase-*.db"))
if err != nil {
return err
}
sort.Strings(files) // Zeitstempel im Namen → chronologisch
for i := 0; i < len(files)-keep; i++ {
if strings.HasPrefix(filepath.Base(files[i]), "physiobase-") {
os.Remove(files[i])
}
}
return nil
}
// RunBackups: sofort beim Start, danach alle 24 h, bis ctx endet.
func RunBackups(ctx context.Context, db *sql.DB, dir string, keep int, log *slog.Logger) {
if keep <= 0 {
log.Info("automatische backups deaktiviert")
return
}
run := func() {
name, err := Backup(ctx, db, dir)
if err != nil {
log.Error("backup fehlgeschlagen", "err", err)
return
}
if err := Prune(dir, keep); err != nil {
log.Warn("alte backups nicht gelöscht", "err", err)
}
log.Info("backup erstellt", "datei", name)
}
run()
t := time.NewTicker(24 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
▾Ainternal/database/backup_test.go
@@ -0,0 +1,47 @@
package database
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestBackupConsistentAndPrune(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "live.db"))
if err != nil {
t.Fatal(err)
}
defer Close(db)
if _, err := db.Exec(`INSERT INTO patients (name) VALUES ('Test')`); err != nil {
t.Fatal(err)
}
bdir := filepath.Join(dir, "backup")
name, err := Backup(context.Background(), db, bdir)
if err != nil {
t.Fatal(err)
}
// Backup muss ohne -wal vollständig lesbar sein
bdb, err := Open(name)
if err != nil {
t.Fatal(err)
}
var n int
bdb.QueryRow(`SELECT count(*) FROM patients`).Scan(&n)
bdb.Close()
if n != 1 {
t.Fatalf("backup enthält %d Patienten", n)
}
for _, f := range []string{"physiobase-2020-01-01_000000.db", "physiobase-2020-01-02_000000.db"} {
os.WriteFile(filepath.Join(bdir, f), nil, 0o600)
}
Prune(bdir, 2)
files, _ := filepath.Glob(filepath.Join(bdir, "physiobase-*.db"))
if len(files) != 2 || filepath.Base(files[1]) != filepath.Base(name) {
t.Fatalf("prune: %v", files)
}
_ = slog.New(slog.NewTextHandler(io.Discard, nil))
}
▾Ainternal/database/database.go
@@ -0,0 +1,221 @@
// Package database öffnet die SQLite-Datei und spielt die Migrationen ein.
package database
import (
"database/sql"
"fmt"
"net/url"
"os"
"path/filepath"
_ "modernc.org/sqlite" // reiner Go-Treiber, kein CGO
)
// Open öffnet (oder erzeugt) die Datenbank. Die Pragmas stehen im DSN, damit sie
// für jede Verbindung gelten – foreign_keys ist in SQLite pro Verbindung.
func Open(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, fmt.Errorf("db-ordner anlegen: %w", err)
}
dsn := "file:" + (&url.URL{Path: path}).EscapedPath() +
"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("db öffnen: %w", err)
}
// Eine Verbindung: keine SQLITE_BUSY-Konflikte zwischen Schreibern. Für eine
// Ein-Personen-Anwendung mehr als schnell genug.
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("db ping: %w", err)
}
if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("migration: %w", err)
}
return db, nil
}
// Close schreibt das WAL zurück in die Hauptdatei und schließt die Datenbank.
func Close(db *sql.DB) error {
if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
db.Close()
return fmt.Errorf("wal checkpoint: %w", err)
}
return db.Close()
}
// Migrationen werden nur angehängt, nie geändert. Jede läuft in einer eigenen
// Transaktion zusammen mit dem Eintrag in schema_migrations.
var migrations = []struct {
version int
sql string
}{
{1, `
CREATE TABLE entries (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
dosage TEXT NOT NULL DEFAULT '',
patient_text TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_entries_kind ON entries(kind);
-- Volltextsuche; remove_diacritics: "Ubung" findet "Übung"
CREATE VIRTUAL TABLE entries_fts USING fts5(
title, summary, tags, region, body, patient_text,
content='entries', content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
INSERT INTO entries_fts(rowid, title, summary, tags, region, body, patient_text)
VALUES (new.id, new.title, new.summary, new.tags, new.region, new.body, new.patient_text);
END;
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
INSERT INTO entries_fts(entries_fts, rowid, title, summary, tags, region, body, patient_text)
VALUES ('delete', old.id, old.title, old.summary, old.tags, old.region, old.body, old.patient_text);
END;
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
INSERT INTO entries_fts(entries_fts, rowid, title, summary, tags, region, body, patient_text)
VALUES ('delete', old.id, old.title, old.summary, old.tags, old.region, old.body, old.patient_text);
INSERT INTO entries_fts(rowid, title, summary, tags, region, body, patient_text)
VALUES (new.id, new.title, new.summary, new.tags, new.region, new.body, new.patient_text);
END;
-- ungerichtete Verknüpfung, immer mit a < b gespeichert
CREATE TABLE entry_links (
a INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
b INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
PRIMARY KEY (a, b),
CHECK (a < b)
) WITHOUT ROWID;
CREATE INDEX idx_links_b ON entry_links(b);
CREATE TABLE sources (
id INTEGER PRIMARY KEY,
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
ref TEXT NOT NULL,
page TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_sources_entry ON sources(entry_id);
CREATE TABLE media (
id INTEGER PRIMARY KEY,
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
filename TEXT NOT NULL UNIQUE,
caption TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_media_entry ON media(entry_id);
CREATE TABLE handouts (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
recipient TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE handout_items (
handout_id INTEGER NOT NULL REFERENCES handouts(id) ON DELETE CASCADE,
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
pos INTEGER NOT NULL DEFAULT 0,
dosage TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
PRIMARY KEY (handout_id, entry_id)
) WITHOUT ROWID;
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) WITHOUT ROWID;
`},
{2, `
CREATE TABLE patients (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
dob TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_patients_name ON patients(name COLLATE NOCASE);
-- Befund: feste Spalten für Liste/Sortierung, Inhalt als JSON (Felder, Tabellen, Körperschema).
-- Die Feldschlüssel entsprechen der alten Befundseite, alte JSON-Exporte lassen sich importieren.
CREATE TABLE befunde (
id INTEGER PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
date TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'Befund',
data TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_befunde_patient ON befunde(patient_id, date);
-- Behandlungsverlauf: gehört zum Patienten, nicht zu einem einzelnen Befund
CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
date TEXT NOT NULL,
goal TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_sessions_patient ON sessions(patient_id, date);
-- Wissen ↔ Patient (z. B. Krankheitsbild, Protokoll)
CREATE TABLE patient_entries (
patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
PRIMARY KEY (patient_id, entry_id)
) WITHOUT ROWID;
ALTER TABLE handouts ADD COLUMN patient_id INTEGER REFERENCES patients(id) ON DELETE SET NULL;
CREATE INDEX idx_handouts_patient ON handouts(patient_id);
`},
}
func migrate(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`); err != nil {
return err
}
var current int
if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&current); err != nil {
return err
}
for _, m := range migrations {
if m.version <= current {
continue
}
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(m.sql); err != nil {
tx.Rollback()
return fmt.Errorf("version %d: %w", m.version, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, m.version); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
▾Ainternal/store/patients.go
@@ -0,0 +1,302 @@
package store
import (
"context"
"database/sql"
"log/slog"
"strings"
"time"
)
type Patient struct {
ID int64
Name, Dob, Phone, Notes string
Archived bool
Created, Updated time.Time
Befunde, Sessions int
LastDate string // letzter Befund oder letzte Behandlung (YYYY-MM-DD)
}
type Befund struct {
ID, PatientID int64
Date, Title string
Data string // JSON, siehe Paket befund
Created, Updated time.Time
}
type Session struct {
ID, PatientID int64
Date, Goal, Note string
}
// ---------- Patienten ----------
func (s *Store) Patients(ctx context.Context, q string, archived bool) ([]Patient, error) {
rows, err := s.db.QueryContext(ctx, `SELECT p.id, p.name, p.dob, p.phone, p.notes, p.archived, p.created_at, p.updated_at,
(SELECT count(*) FROM befunde b WHERE b.patient_id = p.id),
(SELECT count(*) FROM sessions x WHERE x.patient_id = p.id),
COALESCE(max((SELECT max(date) FROM befunde b WHERE b.patient_id = p.id),
(SELECT max(date) FROM sessions x WHERE x.patient_id = p.id)), '')
FROM patients p
WHERE p.archived = ? AND (? = '' OR p.name LIKE '%' || ? || '%' OR p.dob LIKE '%' || ? || '%')
ORDER BY p.updated_at DESC LIMIT 500`, archived, q, q, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Patient
for rows.Next() {
p, err := scanPatient(rows, true)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func scanPatient(sc scanner, withStats bool) (Patient, error) {
var p Patient
var c, u int64
var last sql.NullString
dest := []any{&p.ID, &p.Name, &p.Dob, &p.Phone, &p.Notes, &p.Archived, &c, &u}
if withStats {
dest = append(dest, &p.Befunde, &p.Sessions, &last)
}
err := sc.Scan(dest...)
p.Created, p.Updated, p.LastDate = time.Unix(c, 0), time.Unix(u, 0), last.String
return p, err
}
func (s *Store) Patient(ctx context.Context, id int64) (Patient, error) {
p, err := scanPatient(s.db.QueryRowContext(ctx, `SELECT id, name, dob, phone, notes, archived, created_at, updated_at
FROM patients WHERE id = ?`, id), false)
return p, notFound(err)
}
func (s *Store) CreatePatient(ctx context.Context, p Patient) (int64, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO patients (name, dob, phone, notes) VALUES (?, ?, ?, ?)`, p.Name, p.Dob, p.Phone, p.Notes)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdatePatient(ctx context.Context, p Patient) error {
res, err := s.db.ExecContext(ctx, `UPDATE patients SET name=?, dob=?, phone=?, notes=?, updated_at=unixepoch() WHERE id=?`,
p.Name, p.Dob, p.Phone, p.Notes, p.ID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) SetArchived(ctx context.Context, id int64, archived bool) error {
_, err := s.db.ExecContext(ctx, `UPDATE patients SET archived=?, updated_at=unixepoch() WHERE id=?`, archived, id)
return err
}
func (s *Store) DeletePatient(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM patients WHERE id = ?`, id)
return err
}
// touchPatient sortiert den Patienten in der Liste nach oben; ein Fehler ist nicht kritisch.
func (s *Store) touchPatient(ctx context.Context, id int64) {
if _, err := s.db.ExecContext(ctx, `UPDATE patients SET updated_at = unixepoch() WHERE id = ?`, id); err != nil {
slog.Warn("patient updated_at", "id", id, "err", err)
}
}
// FindPatient sucht exakt nach Name (+ Geburtsdatum, falls angegeben) – für den Import.
func (s *Store) FindPatient(ctx context.Context, name, dob string) (int64, bool, error) {
var id int64
err := s.db.QueryRowContext(ctx, `SELECT id FROM patients WHERE lower(trim(name)) = lower(trim(?))
AND (? = '' OR dob = '' OR dob = ?) ORDER BY id LIMIT 1`, name, dob, dob).Scan(&id)
if err == sql.ErrNoRows {
return 0, false, nil
}
return id, err == nil, err
}
// ---------- Befunde ----------
const befundCols = `id, patient_id, date, title, data, created_at, updated_at`
func scanBefund(sc scanner) (Befund, error) {
var b Befund
var c, u int64
err := sc.Scan(&b.ID, &b.PatientID, &b.Date, &b.Title, &b.Data, &c, &u)
b.Created, b.Updated = time.Unix(c, 0), time.Unix(u, 0)
return b, err
}
func (s *Store) Befunde(ctx context.Context, pid int64) ([]Befund, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+befundCols+` FROM befunde WHERE patient_id = ? ORDER BY date DESC, id DESC`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Befund
for rows.Next() {
b, err := scanBefund(rows)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *Store) Befund(ctx context.Context, id int64) (Befund, error) {
b, err := scanBefund(s.db.QueryRowContext(ctx, `SELECT `+befundCols+` FROM befunde WHERE id = ?`, id))
return b, notFound(err)
}
func (s *Store) CreateBefund(ctx context.Context, b Befund) (int64, error) {
if b.Date == "" {
b.Date = time.Now().Format("2006-01-02")
}
if strings.TrimSpace(b.Title) == "" {
b.Title = "Befund"
}
if b.Data == "" {
b.Data = "{}"
}
res, err := s.db.ExecContext(ctx, `INSERT INTO befunde (patient_id, date, title, data) VALUES (?, ?, ?, ?)`,
b.PatientID, b.Date, b.Title, b.Data)
if err != nil {
return 0, err
}
s.touchPatient(ctx, b.PatientID)
return res.LastInsertId()
}
func (s *Store) UpdateBefund(ctx context.Context, b Befund) error {
res, err := s.db.ExecContext(ctx, `UPDATE befunde SET date=?, title=?, data=?, updated_at=unixepoch() WHERE id=?`,
b.Date, b.Title, b.Data, b.ID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
s.touchPatient(ctx, b.PatientID)
return nil
}
func (s *Store) DeleteBefund(ctx context.Context, id int64) (int64, error) {
var pid int64
err := s.db.QueryRowContext(ctx, `DELETE FROM befunde WHERE id = ? RETURNING patient_id`, id).Scan(&pid)
return pid, notFound(err)
}
// ---------- Behandlungsverlauf ----------
func (s *Store) Sessions(ctx context.Context, pid int64) ([]Session, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, patient_id, date, goal, note FROM sessions
WHERE patient_id = ? ORDER BY date DESC, id DESC`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Session
for rows.Next() {
var x Session
if err := rows.Scan(&x.ID, &x.PatientID, &x.Date, &x.Goal, &x.Note); err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) AddSession(ctx context.Context, x Session) error {
if x.Date == "" {
x.Date = time.Now().Format("2006-01-02")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (patient_id, date, goal, note) VALUES (?, ?, ?, ?)`, x.PatientID, x.Date, x.Goal, x.Note)
if err == nil {
s.touchPatient(ctx, x.PatientID)
}
return err
}
func (s *Store) DeleteSession(ctx context.Context, id int64) (int64, error) {
var pid int64
err := s.db.QueryRowContext(ctx, `DELETE FROM sessions WHERE id = ? RETURNING patient_id`, id).Scan(&pid)
return pid, notFound(err)
}
// ---------- Wissen ↔ Patient ----------
func (s *Store) PatientEntries(ctx context.Context, pid int64) ([]Entry, error) {
return s.entries(ctx, `SELECT `+entryCols+` FROM patient_entries pe JOIN entries e ON e.id = pe.entry_id
WHERE pe.patient_id = ? ORDER BY e.kind, e.title COLLATE NOCASE`, pid)
}
func (s *Store) LinkPatientEntry(ctx context.Context, pid, eid int64) error {
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO patient_entries (patient_id, entry_id) VALUES (?, ?)`, pid, eid)
return err
}
func (s *Store) UnlinkPatientEntry(ctx context.Context, pid, eid int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM patient_entries WHERE patient_id = ? AND entry_id = ?`, pid, eid)
return err
}
// EntriesOfKind z. B. alle Tests als Vorschlagsliste im Befund.
func (s *Store) EntriesOfKind(ctx context.Context, kinds ...string) ([]Entry, error) {
if len(kinds) == 0 {
return s.entries(ctx, `SELECT `+entryCols+` FROM entries e ORDER BY e.kind, e.title COLLATE NOCASE`)
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")
args := make([]any, len(kinds))
for i, k := range kinds {
args[i] = k
}
return s.entries(ctx, `SELECT `+entryCols+` FROM entries e WHERE e.kind IN (`+ph+`) ORDER BY e.kind, e.title COLLATE NOCASE`, args...)
}
// ---------- Handouts je Patient ----------
func (s *Store) HandoutsForPatient(ctx context.Context, pid int64) ([]Handout, error) {
rows, err := s.db.QueryContext(ctx, `SELECT h.id, h.title, h.recipient, h.notes, h.created_at, h.updated_at,
(SELECT count(*) FROM handout_items i WHERE i.handout_id = h.id)
FROM handouts h WHERE h.patient_id = ? ORDER BY h.updated_at DESC`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Handout
for rows.Next() {
var h Handout
var c, u int64
if err := rows.Scan(&h.ID, &h.Title, &h.Recipient, &h.Notes, &c, &u, &h.Count); err != nil {
return nil, err
}
h.Created, h.Updated = time.Unix(c, 0), time.Unix(u, 0)
out = append(out, h)
}
return out, rows.Err()
}
func (s *Store) CreateHandoutFor(ctx context.Context, title string, p Patient) (int64, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO handouts (title, recipient, patient_id) VALUES (?, ?, ?)`, title, p.Name, p.ID)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// HandoutPatient liefert den zugeordneten Patienten eines Handouts (0 = keiner).
func (s *Store) HandoutPatient(ctx context.Context, hid int64) (int64, string, error) {
var pid sql.NullInt64
var name sql.NullString
err := s.db.QueryRowContext(ctx, `SELECT h.patient_id, p.name FROM handouts h LEFT JOIN patients p ON p.id = h.patient_id
WHERE h.id = ?`, hid).Scan(&pid, &name)
return pid.Int64, name.String, notFound(err)
}
▾Ainternal/store/seed.go
@@ -0,0 +1,156 @@
package store
import "context"
// Seed legt einige Beispieleinträge an, aber nur in einer leeren Datenbank.
// Sie zeigen, wie Einträge aufgebaut und verknüpft werden, und dürfen gelöscht werden.
func (s *Store) Seed(ctx context.Context) (bool, error) {
var n int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM entries`).Scan(&n); err != nil {
return false, err
}
if done, _ := s.Setting(ctx, "seeded"); n > 0 || done != "" {
return false, nil
}
ids := map[string]int64{}
for _, e := range seedEntries {
id, err := s.CreateEntry(ctx, e.Entry)
if err != nil {
return false, err
}
ids[e.key] = id
}
for _, l := range seedLinks {
if err := s.Link(ctx, ids[l[0]], ids[l[1]]); err != nil {
return false, err
}
}
if err := s.AddSource(ctx, Source{
EntryID: ids["pt"], Ref: "Eigenes Lehrbuch / Skript (Beispiel)", Page: "S. 123",
Note: "Hier Buch aus der Calibre-Bibliothek eintragen",
}); err != nil {
return false, err
}
hid, err := s.CreateHandout(ctx, "Knie – Sehnenprogramm Woche 1–2", "")
if err != nil {
return false, err
}
for _, k := range []string{"iso", "spanish"} {
if err := s.AddHandoutItem(ctx, hid, ids[k]); err != nil {
return false, err
}
}
return true, s.SetSetting(ctx, "seeded", "1")
}
type seedEntry struct {
key string
Entry
}
var seedLinks = [][2]string{
{"pt", "iso"},
{"pt", "spanish"},
{"pt", "decline"},
{"pt", "visa"},
{"pt", "proto"},
{"proto", "iso"},
{"proto", "spanish"},
{"proto", "decline"},
}
var seedEntries = []seedEntry{
{"pt", Entry{
Kind: "krankheitsbild", Title: "Patellatendinopathie (Jumper's Knee)", Region: "Knie",
Tags: "Sehne, Überlastung, Sprungsport, Beispiel",
Summary: "Belastungsabhängiger Schmerz am unteren Patellapol, typisch bei Sprung- und Sprintsportarten.",
Body: `## Befund
- Schmerz lokal am unteren Patellapol, **belastungsabhängig** (Springen, Treppe, tiefe Hocke)
- Typisch: Anlaufschmerz, wird beim Aufwärmen besser, danach / am Folgetag schlechter
- Druckschmerz allein ist wenig spezifisch
## Red Flags / Differenzialdiagnosen
- Schwellung, Erguss, Blockaden → an intraartikuläre Ursache denken
- Jugendliche: Sinding-Larsen-Johansson / Osgood-Schlatter
## Behandlungsidee
1. **Belastung steuern**, nicht komplett pausieren (Schmerz während Übung bis ca. 3–4/10 meist akzeptabel, am Folgetag nicht schlechter)
2. Isometrie zur Schmerzmodulation
3. Schwere, langsame Kraftübungen (Heavy Slow Resistance)
4. Energiespeicher-Belastung (Sprünge) schrittweise zurück
5. Zurück in den Sport
## Eigene Notizen
_Hier eigene Erfahrungen ergänzen._`,
}},
{"visa", Entry{
Kind: "test", Title: "VISA-P Fragebogen", Region: "Knie", Tags: "Fragebogen, Verlaufskontrolle, Beispiel",
Summary: "Fragebogen zu Symptomen und Funktion bei Patellatendinopathie (0–100, höher = besser). Gut für die Verlaufskontrolle.",
Body: `## Durchführung
Patient füllt 8 Fragen aus (Schmerz, Funktion, Sportfähigkeit).
## Interpretation
- 100 = beschwerdefrei
- Verlauf alle 2–4 Wochen dokumentieren`,
}},
{"iso", Entry{
Kind: "uebung", Title: "Isometrische Kniestreckung (Wandsitz)", Region: "Knie",
Tags: "Isometrie, Schmerzlinderung, Sehne, Beispiel",
Summary: "Statisches Halten zur Schmerzlinderung bei Sehnenbeschwerden, gut als Einstieg.",
Dosage: "5 × 45 Sekunden halten, 2 Min. Pause, täglich",
Body: `## Ziel
Schmerzmodulation, Belastbarkeit aufbauen.
## Progression
Winkel tiefer → einbeinig → mit Zusatzgewicht
## Hinweise für Therapeut
Schmerz während der Übung bis etwa 3/10 ist meist in Ordnung.`,
PatientText: `Lehnen Sie sich mit dem Rücken an eine Wand und rutschen Sie langsam nach unten, bis die Knie etwa **60 Grad** gebeugt sind.
- Halten Sie die Position ruhig, atmen Sie dabei weiter.
- Die Knie zeigen in Richtung der Fußspitzen.
- Ein leichtes Ziehen an der Sehne ist in Ordnung. Wird der Schmerz stärker, gehen Sie weniger tief.`,
}},
{"spanish", Entry{
Kind: "uebung", Title: "Spanish Squat", Region: "Knie",
Tags: "Isometrie, Quadrizeps, Sehne, Beispiel",
Summary: "Kniebeuge mit Band hinter den Knien. Belastet den Quadrizeps stark bei wenig Kniescherkräften.",
Dosage: "5 × 45 Sekunden halten",
Body: `## Aufbau
Festes Band (z. B. Gurt) an einem stabilen Pfosten, Band in den Kniekehlen.
## Progression
Tiefer → länger halten → dynamisch`,
PatientText: `Legen Sie das Band in die Kniekehlen und lehnen Sie sich nach hinten hinein. Gehen Sie in die Hocke, die **Schienbeine bleiben senkrecht**.
- Oberkörper aufrecht, Gewicht auf den Fersen.
- Position halten, ruhig weiteratmen.`,
}},
{"decline", Entry{
Kind: "uebung", Title: "Kniebeuge auf Schrägbrett (exzentrisch)", Region: "Knie",
Tags: "Exzentrik, Kraft, Sehne, Beispiel",
Summary: "Langsame Kniebeuge auf einem schrägen Brett, betont die Patellarsehne.",
Dosage: "3 × 15 Wiederholungen, 3 Sek. runter",
Body: `## Hinweise
Erst wenn Isometrie gut vertragen wird. Schmerz am Folgetag beobachten.`,
PatientText: `Stellen Sie sich auf das Schrägbrett, Fersen oben. Gehen Sie **langsam (3 Sekunden)** in die Kniebeuge bis etwa 60–70 Grad.
- Hochkommen mit beiden Beinen.
- Die Knie zeigen in Richtung der Fußspitzen.`,
}},
{"proto", Entry{
Kind: "protokoll", Title: "Sehnen-Rehabilitation – Stufenmodell", Region: "Allgemein",
Tags: "Tendinopathie, Belastungsaufbau, Beispiel",
Summary: "Allgemeiner Stufenplan für Sehnenbeschwerden: von Isometrie bis zur Rückkehr in den Sport.",
Body: `| Stufe | Inhalt | Weiter wenn |
|---|---|---|
| 1 | Isometrie, mehrmals täglich | Schmerz während Übung ≤ 3/10 |
| 2 | Schwere, langsame Kraftübungen, 3×/Woche | Kraft steigt, Folgetag stabil |
| 3 | Energiespeicher: Hüpfen, Sprünge | Sprünge schmerzarm |
| 4 | Sportspezifisch, Rückkehr | Volle Belastung toleriert |
**Regel:** Beschwerden am Folgetag sind der wichtigste Maßstab.`,
}},
}
▾Ainternal/store/store.go
@@ -0,0 +1,533 @@
// Package store kapselt alle SQL-Abfragen. Wichtig bei MaxOpenConns(1): nie eine
// zweite Abfrage starten, solange noch Rows offen sind.
package store
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"unicode"
)
var ErrNotFound = errors.New("nicht gefunden")
type Store struct{ db *sql.DB }
func New(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
// ---------- Typen ----------
type Kind struct{ Key, Label, Plural string }
var Kinds = []Kind{
{"krankheitsbild", "Krankheitsbild", "Krankheitsbilder"},
{"test", "Test / Assessment", "Tests"},
{"uebung", "Übung", "Übungen"},
{"protokoll", "Behandlungsprotokoll", "Protokolle"},
{"notiz", "Notiz", "Notizen"},
}
func KindLabel(k string) string {
for _, x := range Kinds {
if x.Key == k {
return x.Label
}
}
return k
}
func ValidKind(k string) bool {
for _, x := range Kinds {
if x.Key == k {
return true
}
}
return false
}
type Entry struct {
ID int64
Kind, Title, Summary, Body, Region, Tags, Dosage, PatientText string
Created, Updated time.Time
}
type Source struct {
ID, EntryID int64
Ref, Page, URL, Note string
}
type Media struct {
ID, EntryID int64
Filename, Caption string
}
type Handout struct {
ID int64
Title, Recipient, Notes string
Created, Updated time.Time
Count int
}
type HandoutItem struct {
Pos int
Dosage, Note string
Entry Entry
Media []Media
}
func notFound(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
// ---------- Einträge ----------
const entryCols = `e.id, e.kind, e.title, e.summary, e.body, e.region, e.tags, e.dosage, e.patient_text, e.created_at, e.updated_at`
type scanner interface{ Scan(...any) error }
func scanEntry(sc scanner) (Entry, error) {
var e Entry
var c, u int64
err := sc.Scan(&e.ID, &e.Kind, &e.Title, &e.Summary, &e.Body, &e.Region, &e.Tags, &e.Dosage, &e.PatientText, &c, &u)
e.Created, e.Updated = time.Unix(c, 0), time.Unix(u, 0)
return e, err
}
func (s *Store) entries(ctx context.Context, q string, args ...any) ([]Entry, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// FTSQuery macht aus Benutzereingaben eine sichere FTS5-Abfrage: jedes Wort wird
// zum Präfix-Term ("knie" findet "Kniegelenk"), alle Wörter müssen vorkommen.
func FTSQuery(input string) string {
words := strings.FieldsFunc(input, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
terms := make([]string, 0, len(words))
for _, w := range words {
terms = append(terms, `"`+w+`"*`)
}
return strings.Join(terms, " ")
}
// Search durchsucht die Wissensdatenbank. Leere Suche liefert alles (gefiltert).
func (s *Store) Search(ctx context.Context, q, kind, region string) ([]Entry, error) {
if fts := FTSQuery(q); fts != "" {
// bm25: Treffer im Titel zählen am meisten
return s.entries(ctx, `SELECT `+entryCols+` FROM entries_fts f JOIN entries e ON e.id = f.rowid
WHERE entries_fts MATCH ? AND (? = '' OR e.kind = ?) AND (? = '' OR e.region = ?)
ORDER BY bm25(entries_fts, 10, 4, 4, 3, 1, 1) LIMIT 500`, fts, kind, kind, region, region)
}
return s.entries(ctx, `SELECT `+entryCols+` FROM entries e
WHERE (? = '' OR e.kind = ?) AND (? = '' OR e.region = ?)
ORDER BY e.title COLLATE NOCASE LIMIT 500`, kind, kind, region, region)
}
func (s *Store) KindCounts(ctx context.Context) (map[string]int, int, error) {
rows, err := s.db.QueryContext(ctx, `SELECT kind, count(*) FROM entries GROUP BY kind`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out, total := map[string]int{}, 0
for rows.Next() {
var k string
var n int
if err := rows.Scan(&k, &n); err != nil {
return nil, 0, err
}
out[k] = n
total += n
}
return out, total, rows.Err()
}
func (s *Store) Regions(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT region FROM entries WHERE region <> '' ORDER BY region COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) Entry(ctx context.Context, id int64) (Entry, error) {
e, err := scanEntry(s.db.QueryRowContext(ctx, `SELECT `+entryCols+` FROM entries e WHERE e.id = ?`, id))
return e, notFound(err)
}
func (s *Store) CreateEntry(ctx context.Context, e Entry) (int64, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO entries (kind, title, summary, body, region, tags, dosage, patient_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, e.Kind, e.Title, e.Summary, e.Body, e.Region, e.Tags, e.Dosage, e.PatientText)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateEntry(ctx context.Context, e Entry) error {
res, err := s.db.ExecContext(ctx, `UPDATE entries SET kind=?, title=?, summary=?, body=?, region=?, tags=?,
dosage=?, patient_text=?, updated_at=unixepoch() WHERE id=?`,
e.Kind, e.Title, e.Summary, e.Body, e.Region, e.Tags, e.Dosage, e.PatientText, e.ID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteEntry löscht den Eintrag und gibt die Dateinamen seiner Medien zurück,
// damit der Aufrufer sie von der Platte entfernen kann.
func (s *Store) DeleteEntry(ctx context.Context, id int64) ([]string, error) {
media, err := s.Media(ctx, id)
if err != nil {
return nil, err
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM entries WHERE id = ?`, id); err != nil {
return nil, err
}
files := make([]string, len(media))
for i, m := range media {
files[i] = m.Filename
}
return files, nil
}
func (s *Store) Related(ctx context.Context, id int64) ([]Entry, error) {
return s.entries(ctx, `SELECT `+entryCols+` FROM entry_links l
JOIN entries e ON e.id = CASE WHEN l.a = ? THEN l.b ELSE l.a END
WHERE l.a = ? OR l.b = ? ORDER BY e.kind, e.title COLLATE NOCASE`, id, id, id)
}
// Linkable: alle Einträge außer dem aktuellen und bereits verknüpften.
func (s *Store) Linkable(ctx context.Context, id int64) ([]Entry, error) {
return s.entries(ctx, `SELECT `+entryCols+` FROM entries e WHERE e.id <> ?
AND e.id NOT IN (SELECT b FROM entry_links WHERE a = ? UNION SELECT a FROM entry_links WHERE b = ?)
ORDER BY e.kind, e.title COLLATE NOCASE`, id, id, id)
}
func (s *Store) Link(ctx context.Context, x, y int64) error {
if x == y {
return nil
}
if x > y {
x, y = y, x
}
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO entry_links (a, b) VALUES (?, ?)`, x, y)
return err
}
func (s *Store) Unlink(ctx context.Context, x, y int64) error {
if x > y {
x, y = y, x
}
_, err := s.db.ExecContext(ctx, `DELETE FROM entry_links WHERE a = ? AND b = ?`, x, y)
return err
}
// ---------- Quellen ----------
func (s *Store) Sources(ctx context.Context, entryID int64) ([]Source, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, entry_id, ref, page, url, note FROM sources WHERE entry_id = ? ORDER BY id`, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Source
for rows.Next() {
var x Source
if err := rows.Scan(&x.ID, &x.EntryID, &x.Ref, &x.Page, &x.URL, &x.Note); err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) AddSource(ctx context.Context, x Source) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sources (entry_id, ref, page, url, note) VALUES (?, ?, ?, ?, ?)`,
x.EntryID, x.Ref, x.Page, x.URL, x.Note)
return err
}
func (s *Store) DeleteSource(ctx context.Context, id int64) (entryID int64, err error) {
err = s.db.QueryRowContext(ctx, `DELETE FROM sources WHERE id = ? RETURNING entry_id`, id).Scan(&entryID)
return entryID, notFound(err)
}
// ---------- Medien ----------
func (s *Store) Media(ctx context.Context, entryID int64) ([]Media, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, entry_id, filename, caption FROM media WHERE entry_id = ? ORDER BY id`, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Media
for rows.Next() {
var m Media
if err := rows.Scan(&m.ID, &m.EntryID, &m.Filename, &m.Caption); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) AddMedia(ctx context.Context, m Media) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO media (entry_id, filename, caption) VALUES (?, ?, ?)`, m.EntryID, m.Filename, m.Caption)
return err
}
func (s *Store) DeleteMedia(ctx context.Context, id int64) (Media, error) {
var m Media
err := s.db.QueryRowContext(ctx, `DELETE FROM media WHERE id = ? RETURNING id, entry_id, filename, caption`, id).
Scan(&m.ID, &m.EntryID, &m.Filename, &m.Caption)
return m, notFound(err)
}
// MediaKnown prüft, ob eine Datei zu einem Eintrag gehört (nur solche werden ausgeliefert).
func (s *Store) MediaKnown(ctx context.Context, filename string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM media WHERE filename = ?`, filename).Scan(&n)
return n > 0, err
}
// ---------- Handouts ----------
func (s *Store) Handouts(ctx context.Context) ([]Handout, error) {
rows, err := s.db.QueryContext(ctx, `SELECT h.id, h.title, h.recipient, h.notes, h.created_at, h.updated_at,
(SELECT count(*) FROM handout_items i WHERE i.handout_id = h.id)
FROM handouts h ORDER BY h.updated_at DESC, h.id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Handout
for rows.Next() {
var h Handout
var c, u int64
if err := rows.Scan(&h.ID, &h.Title, &h.Recipient, &h.Notes, &c, &u, &h.Count); err != nil {
return nil, err
}
h.Created, h.Updated = time.Unix(c, 0), time.Unix(u, 0)
out = append(out, h)
}
return out, rows.Err()
}
func (s *Store) Handout(ctx context.Context, id int64) (Handout, error) {
var h Handout
var c, u int64
err := s.db.QueryRowContext(ctx, `SELECT id, title, recipient, notes, created_at, updated_at FROM handouts WHERE id = ?`, id).
Scan(&h.ID, &h.Title, &h.Recipient, &h.Notes, &c, &u)
h.Created, h.Updated = time.Unix(c, 0), time.Unix(u, 0)
return h, notFound(err)
}
func (s *Store) CreateHandout(ctx context.Context, title, recipient string) (int64, error) {
res, err := s.db.ExecContext(ctx, `INSERT INTO handouts (title, recipient) VALUES (?, ?)`, title, recipient)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateHandout(ctx context.Context, h Handout) error {
_, err := s.db.ExecContext(ctx, `UPDATE handouts SET title=?, recipient=?, notes=?, updated_at=unixepoch() WHERE id=?`,
h.Title, h.Recipient, h.Notes, h.ID)
return err
}
func (s *Store) DeleteHandout(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM handouts WHERE id = ?`, id)
return err
}
// CopyHandout dupliziert ein Handout als Vorlage für den nächsten Patienten.
func (s *Store) CopyHandout(ctx context.Context, id int64) (int64, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var nid int64
if err := tx.QueryRowContext(ctx, `INSERT INTO handouts (title, recipient, notes)
SELECT title || ' (Kopie)', '', notes FROM handouts WHERE id = ? RETURNING id`, id).Scan(&nid); err != nil {
return 0, notFound(err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO handout_items (handout_id, entry_id, pos, dosage, note)
SELECT ?, entry_id, pos, dosage, note FROM handout_items WHERE handout_id = ?`, nid, id); err != nil {
return 0, err
}
return nid, tx.Commit()
}
func (s *Store) HandoutItems(ctx context.Context, hid int64) ([]HandoutItem, error) {
out, err := s.handoutItemRows(ctx, hid)
if err != nil {
return nil, err
}
// Medien erst nach dem Schließen der Rows laden: nur eine Verbindung
for i := range out {
if out[i].Media, err = s.Media(ctx, out[i].Entry.ID); err != nil {
return nil, err
}
}
return out, nil
}
func (s *Store) handoutItemRows(ctx context.Context, hid int64) ([]HandoutItem, error) {
rows, err := s.db.QueryContext(ctx, `SELECT i.pos, i.dosage, i.note, `+entryCols+`
FROM handout_items i JOIN entries e ON e.id = i.entry_id
WHERE i.handout_id = ? ORDER BY i.pos, e.title COLLATE NOCASE`, hid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HandoutItem
for rows.Next() {
var it HandoutItem
e := &it.Entry
var c, u int64
if err := rows.Scan(&it.Pos, &it.Dosage, &it.Note, &e.ID, &e.Kind, &e.Title, &e.Summary, &e.Body,
&e.Region, &e.Tags, &e.Dosage, &e.PatientText, &c, &u); err != nil {
return nil, err
}
e.Created, e.Updated = time.Unix(c, 0), time.Unix(u, 0)
out = append(out, it)
}
return out, rows.Err()
}
// ExercisesNotIn listet Übungen, die noch nicht im Handout sind.
func (s *Store) ExercisesNotIn(ctx context.Context, hid int64) ([]Entry, error) {
return s.entries(ctx, `SELECT `+entryCols+` FROM entries e WHERE e.kind = 'uebung'
AND e.id NOT IN (SELECT entry_id FROM handout_items WHERE handout_id = ?)
ORDER BY e.region COLLATE NOCASE, e.title COLLATE NOCASE`, hid)
}
func (s *Store) AddHandoutItem(ctx context.Context, hid, eid int64) error {
res, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO handout_items (handout_id, entry_id, pos, dosage)
SELECT ?, e.id, COALESCE((SELECT max(pos) FROM handout_items WHERE handout_id = ?), 0) + 1, e.dosage
FROM entries e WHERE e.id = ? AND e.kind = 'uebung'
AND EXISTS (SELECT 1 FROM handouts WHERE id = ?)`, hid, hid, eid, hid)
if err != nil {
return err
}
return s.touchHandout(ctx, hid, res)
}
func (s *Store) touchHandout(ctx context.Context, hid int64, res sql.Result) error {
if n, _ := res.RowsAffected(); n > 0 {
_, err := s.db.ExecContext(ctx, `UPDATE handouts SET updated_at = unixepoch() WHERE id = ?`, hid)
return err
}
return nil
}
func (s *Store) UpdateHandoutItem(ctx context.Context, hid, eid int64, dosage, note string) error {
res, err := s.db.ExecContext(ctx, `UPDATE handout_items SET dosage=?, note=? WHERE handout_id=? AND entry_id=?`, dosage, note, hid, eid)
if err != nil {
return err
}
return s.touchHandout(ctx, hid, res)
}
func (s *Store) RemoveHandoutItem(ctx context.Context, hid, eid int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM handout_items WHERE handout_id=? AND entry_id=?`, hid, eid)
if err != nil {
return err
}
return s.touchHandout(ctx, hid, res)
}
// MoveHandoutItemUp tauscht die Übung mit ihrer Vorgängerin und nummeriert neu.
func (s *Store) MoveHandoutItemUp(ctx context.Context, hid, eid int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
order, err := handoutOrder(ctx, tx, hid)
if err != nil {
return err
}
for i := 1; i < len(order); i++ {
if order[i] == eid {
order[i-1], order[i] = order[i], order[i-1]
break
}
}
for i, id := range order {
if _, err := tx.ExecContext(ctx, `UPDATE handout_items SET pos = ? WHERE handout_id = ? AND entry_id = ?`, i+1, hid, id); err != nil {
return err
}
}
return tx.Commit()
}
func handoutOrder(ctx context.Context, tx *sql.Tx, hid int64) ([]int64, error) {
rows, err := tx.QueryContext(ctx, `SELECT i.entry_id FROM handout_items i JOIN entries e ON e.id = i.entry_id
WHERE i.handout_id = ? ORDER BY i.pos, e.title COLLATE NOCASE`, hid)
if err != nil {
return nil, err
}
defer rows.Close()
var order []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
order = append(order, id)
}
return order, rows.Err()
}
// ---------- Einstellungen ----------
func (s *Store) Setting(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
▾Ainternal/store/store_test.go
@@ -0,0 +1,135 @@
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"forge.gpl-nas.de/physiobase/internal/database"
)
func newStore(t *testing.T) *Store {
t.Helper()
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close(db) })
return New(db)
}
func TestFTSQuery(t *testing.T) {
cases := map[string]string{
"": "",
"knie": `"knie"*`,
`knie "OR" sehne*`: `"knie"* "OR"* "sehne"*`,
"Lachman-Test": `"Lachman"* "Test"*`,
`"); DROP`: `"DROP"*`,
}
for in, want := range cases {
if got := FTSQuery(in); got != want {
t.Errorf("FTSQuery(%q) = %q, want %q", in, got, want)
}
}
}
func TestSeedSearchLinks(t *testing.T) {
ctx := context.Background()
s := newStore(t)
seeded, err := s.Seed(ctx)
if err != nil || !seeded {
t.Fatalf("seed: %v %v", seeded, err)
}
// zweites Mal: nichts passiert
if again, _ := s.Seed(ctx); again {
t.Fatal("seed lief zweimal")
}
// Präfix + Umlaut-unabhängig
for _, q := range []string{"patella", "Ubung", "kniebeuge schräg", "jumper"} {
res, err := s.Search(ctx, q, "", "")
if err != nil {
t.Fatal(err)
}
if len(res) == 0 {
t.Errorf("Suche %q: keine Treffer", q)
}
}
res, _ := s.Search(ctx, "", "uebung", "Knie")
if len(res) != 3 {
t.Errorf("Filter Übung+Knie: %d Treffer, erwartet 3", len(res))
}
all, _ := s.Search(ctx, "Patellatendinopathie", "krankheitsbild", "")
if len(all) != 1 {
t.Fatalf("Krankheitsbild nicht gefunden")
}
rel, err := s.Related(ctx, all[0].ID)
if err != nil || len(rel) != 5 {
t.Fatalf("Related: %d %v", len(rel), err)
}
// Update hält den Suchindex aktuell
e := all[0]
e.Title = "Springerknie"
if err := s.UpdateEntry(ctx, e); err != nil {
t.Fatal(err)
}
if r, _ := s.Search(ctx, "springerknie", "", ""); len(r) != 1 {
t.Error("geänderter Titel nicht im Index")
}
if r, _ := s.Search(ctx, "Patellatendinopathie", "krankheitsbild", ""); len(r) != 0 {
t.Error("alter Titel noch im Index")
}
// Löschen entfernt Verknüpfungen (Fremdschlüssel aktiv)
if _, err := s.DeleteEntry(ctx, e.ID); err != nil {
t.Fatal(err)
}
var n int
s.db.QueryRow(`SELECT count(*) FROM entry_links WHERE a = ? OR b = ?`, e.ID, e.ID).Scan(&n)
if n != 0 {
t.Errorf("%d Verknüpfungen übrig", n)
}
if _, err := s.Entry(ctx, e.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("gelöschter Eintrag: %v", err)
}
}
func TestHandoutOrderAndCopy(t *testing.T) {
ctx := context.Background()
s := newStore(t)
var ids []int64
for _, title := range []string{"A", "B", "C"} {
id, _ := s.CreateEntry(ctx, Entry{Kind: "uebung", Title: title, Dosage: "3x10"})
ids = append(ids, id)
}
notEx, _ := s.CreateEntry(ctx, Entry{Kind: "test", Title: "kein Übung"})
hid, _ := s.CreateHandout(ctx, "H", "")
for _, id := range append(ids, notEx) {
if err := s.AddHandoutItem(ctx, hid, id); err != nil {
t.Fatal(err)
}
}
items, _ := s.HandoutItems(ctx, hid)
if len(items) != 3 {
t.Fatalf("%d Items, erwartet 3 (Tests dürfen nicht rein)", len(items))
}
if items[0].Dosage != "3x10" {
t.Error("Standard-Dosierung nicht übernommen")
}
if err := s.MoveHandoutItemUp(ctx, hid, ids[2]); err != nil {
t.Fatal(err)
}
items, _ = s.HandoutItems(ctx, hid)
got := items[0].Entry.Title + items[1].Entry.Title + items[2].Entry.Title
if got != "ACB" {
t.Errorf("Reihenfolge %s, erwartet ACB", got)
}
nid, err := s.CopyHandout(ctx, hid)
if err != nil {
t.Fatal(err)
}
cp, _ := s.HandoutItems(ctx, nid)
if len(cp) != 3 || cp[1].Entry.Title != "C" {
t.Error("Kopie unvollständig")
}
}
▾Ainternal/web/auth.go
@@ -0,0 +1,166 @@
package web
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Sitzung = signiertes Cookie (Ablaufzeit + HMAC), kein Serverzustand.
// Der Schlüssel enthält ein gespeichertes Geheimnis; Abmelden erneuert es und
// macht damit alle bisherigen Cookies ungültig (z. B. verlorenes Tablet).
const (
sessionCookie = "physiobase_session"
sessionTTL = 30 * 24 * time.Hour
maxConcurrentLogins = 2 // bcrypt ist absichtlich langsam
loginWait = 3 * time.Second // so lange wird auf einen Slot gewartet
maxLoginBody = 4 << 10
)
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
func (s *Server) loadSessionSecret(ctx context.Context) error {
secret, err := s.st.Setting(ctx, "session_secret")
if err != nil {
return err
}
if secret == "" {
secret = randomHex(32)
if err := s.st.SetSetting(ctx, "session_secret", secret); err != nil {
return err
}
}
s.sessionSecret = secret
return nil
}
func (s *Server) rotateSessionSecret(ctx context.Context) error {
secret := randomHex(32)
if err := s.st.SetSetting(ctx, "session_secret", secret); err != nil {
return err
}
s.sessionMu.Lock()
s.sessionSecret = secret
s.sessionMu.Unlock()
return nil
}
func (s *Server) sessionKey() []byte {
s.sessionMu.RLock()
secret := s.sessionSecret
s.sessionMu.RUnlock()
// Benutzername/Hash mit drin: Passwortwechsel beendet alle Sitzungen
sum := sha256.Sum256([]byte(s.cfg.Username + ":" + s.cfg.PasswordHash + ":" + secret))
return sum[:]
}
func (s *Server) sign(exp int64) string {
m := hmac.New(sha256.New, s.sessionKey())
m.Write([]byte(strconv.FormatInt(exp, 10)))
return hex.EncodeToString(m.Sum(nil))
}
func (s *Server) setCookie(w http.ResponseWriter, value string, maxAge int) {
// Secure nur hinter HTTPS (PHYSIOBASE_BASE_URL=https://…); im LAN läuft die App auch per HTTP.
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure hängt bewusst von der Konfiguration ab
Name: sessionCookie, Value: value, Path: "/", MaxAge: maxAge,
HttpOnly: true, Secure: s.cfg.IsHTTPS(), SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) hasSession(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
raw, mac, ok := strings.Cut(c.Value, "|")
if !ok {
return false
}
exp, err := strconv.ParseInt(raw, 10, 64)
if err != nil || !hmac.Equal([]byte(mac), []byte(s.sign(exp))) {
return false
}
return time.Now().Unix() < exp
}
func publicPath(p string) bool {
return p == "/login" || p == "/healthz" || strings.HasPrefix(p, "/static/")
}
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if publicPath(r.URL.Path) || s.hasSession(r) {
next.ServeHTTP(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
}
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
if s.hasSession(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login", http.StatusOK, nil)
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
if err := r.ParseForm(); err != nil {
http.Error(w, "Formular ungültig", http.StatusBadRequest)
return
}
// Erst einen Slot holen, dann hashen – begrenzt die CPU-Last bei vielen Versuchen.
select {
case s.loginSem <- struct{}{}:
defer func() { <-s.loginSem }()
case <-time.After(loginWait):
s.render(w, r, "login", http.StatusTooManyRequests, map[string]any{"Error": "Zu viele Anmeldeversuche, bitte gleich nochmal."})
return
}
// Passwort immer prüfen, auch bei falschem Benutzernamen (gleiche Laufzeit).
userOK := subtle.ConstantTimeCompare([]byte(r.PostFormValue("username")), []byte(s.cfg.Username)) == 1
passOK := bcrypt.CompareHashAndPassword([]byte(s.cfg.PasswordHash), []byte(r.PostFormValue("password"))) == nil
if !userOK || !passOK {
s.log.Warn("fehlgeschlagene anmeldung", "remote", r.RemoteAddr)
time.Sleep(time.Second)
s.render(w, r, "login", http.StatusUnauthorized, map[string]any{"Error": "Benutzername oder Passwort falsch."})
return
}
exp := time.Now().Add(sessionTTL).Unix()
s.setCookie(w, fmt.Sprintf("%d|%s", exp, s.sign(exp)), int(sessionTTL.Seconds()))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if s.hasSession(r) {
if err := s.rotateSessionSecret(r.Context()); err != nil {
s.fail(w, r, err)
return
}
}
s.setCookie(w, "", -1)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// HashPassword wird vom Unterbefehl "hash-password" benutzt.
func HashPassword(pw string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(pw), 12)
return string(h), err
}
▾Ainternal/web/entries.go
@@ -0,0 +1,275 @@
package web
import (
"net/http"
"os"
"path/filepath"
"strings"
"forge.gpl-nas.de/physiobase/internal/store"
)
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := strings.TrimSpace(r.URL.Query().Get("q"))
kind := r.URL.Query().Get("art")
if !store.ValidKind(kind) {
kind = ""
}
region := strings.TrimSpace(r.URL.Query().Get("region"))
entries, err := s.st.Search(ctx, q, kind, region)
if err != nil {
s.fail(w, r, err)
return
}
counts, total, err := s.st.KindCounts(ctx)
if err != nil {
s.fail(w, r, err)
return
}
regions, err := s.st.Regions(ctx)
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "index", http.StatusOK, map[string]any{
"Entries": entries, "Q": q, "Art": kind, "Region": region,
"Counts": counts, "Total": total, "Regions": regions,
})
}
type group struct {
Label string
Entries []store.Entry
}
func (s *Server) showEntry(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
e, err := s.st.Entry(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
related, err := s.st.Related(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
linkable, err := s.st.Linkable(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
sources, err := s.st.Sources(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
media, err := s.st.Media(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
var handouts []store.Handout
if e.Kind == "uebung" {
if handouts, err = s.st.Handouts(ctx); err != nil {
s.fail(w, r, err)
return
}
}
// Verknüpfte Einträge nach Art gruppieren, in fester Reihenfolge
var groups []group
for _, k := range store.Kinds {
g := group{Label: k.Plural}
for _, x := range related {
if x.Kind == k.Key {
g.Entries = append(g.Entries, x)
}
}
if len(g.Entries) > 0 {
groups = append(groups, g)
}
}
s.render(w, r, "entry", http.StatusOK, map[string]any{
"E": e, "Groups": groups, "Linkable": linkable, "Sources": sources,
"Media": media, "Handouts": handouts, "Added": r.URL.Query().Get("handout"),
})
}
func (s *Server) newEntry(w http.ResponseWriter, r *http.Request) {
regions, err := s.st.Regions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
kind := r.URL.Query().Get("art")
if !store.ValidKind(kind) {
kind = "krankheitsbild"
}
s.render(w, r, "edit", http.StatusOK, map[string]any{
"E": store.Entry{Kind: kind, Region: r.URL.Query().Get("region")}, "New": true,
"Regions": regions, "LinkFrom": r.URL.Query().Get("von"),
})
}
func (s *Server) editEntry(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
e, err := s.st.Entry(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
regions, err := s.st.Regions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "edit", http.StatusOK, map[string]any{"E": e, "Regions": regions})
}
func (s *Server) saveEntry(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
ctx := r.Context()
e := store.Entry{
Kind: formText(r, "kind"), Title: formText(r, "title"), Summary: formText(r, "summary"),
Body: formText(r, "body"), Region: formText(r, "region"), Tags: formText(r, "tags"),
Dosage: formText(r, "dosage"), PatientText: formText(r, "patient_text"),
}
if !store.ValidKind(e.Kind) || e.Title == "" {
regions, _ := s.st.Regions(ctx)
id, _ := pathID(r, "id")
e.ID = id
s.render(w, r, "edit", http.StatusBadRequest, map[string]any{
"E": e, "New": id == 0, "Regions": regions, "LinkFrom": r.FormValue("link_from"),
"Error": "Bitte einen Titel angeben.",
})
return
}
if r.PathValue("id") == "" {
id, err := s.st.CreateEntry(ctx, e)
if err != nil {
s.fail(w, r, err)
return
}
// "Neuen Eintrag verknüpft anlegen" vom Ausgangseintrag aus
if from, ok := formID(r, "link_from"); ok {
if err := s.st.Link(ctx, from, id); err != nil {
s.fail(w, r, err)
return
}
}
redirect(w, r, "/eintrag/%d", id)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
e.ID = id
if err := s.st.UpdateEntry(ctx, e); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/eintrag/%d", id)
}
func (s *Server) deleteEntry(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
files, err := s.st.DeleteEntry(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
for _, f := range files {
// Dateinamen stammen aus der Datenbank (vom Server erzeugt), nicht vom Benutzer.
if err := os.Remove(filepath.Join(s.cfg.MediaDir, f)); err != nil && !os.IsNotExist(err) { //nolint:gosec // G703
s.log.Warn("medium nicht gelöscht", "datei", f, "err", err)
}
}
redirect(w, r, "/")
}
func (s *Server) linkEntry(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok1 := pathID(r, "id")
other, ok2 := formID(r, "other")
if !ok1 || !ok2 {
http.Error(w, "Bitte einen Eintrag auswählen", http.StatusBadRequest)
return
}
if err := s.st.Link(r.Context(), id, other); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/eintrag/%d#verknuepft", id)
}
func (s *Server) unlinkEntry(w http.ResponseWriter, r *http.Request) {
id, ok1 := pathID(r, "id")
other, ok2 := pathID(r, "other")
if !ok1 || !ok2 {
http.NotFound(w, r)
return
}
if err := s.st.Unlink(r.Context(), id, other); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/eintrag/%d#verknuepft", id)
}
func (s *Server) addSource(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
src := store.Source{EntryID: id, Ref: formText(r, "ref"), Page: formText(r, "page"), URL: formText(r, "url"), Note: formText(r, "note")}
// Nur http(s)-Links zulassen
if src.URL != "" && !strings.HasPrefix(src.URL, "http://") && !strings.HasPrefix(src.URL, "https://") {
src.URL = "https://" + src.URL
}
if src.Ref != "" {
if err := s.st.AddSource(r.Context(), src); err != nil {
s.fail(w, r, err)
return
}
}
redirect(w, r, "/eintrag/%d#quellen", id)
}
func (s *Server) deleteSource(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
eid, err := s.st.DeleteSource(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/eintrag/%d#quellen", eid)
}
▾Ainternal/web/handouts.go
@@ -0,0 +1,229 @@
package web
import (
"net/http"
"time"
"forge.gpl-nas.de/physiobase/internal/store"
)
func (s *Server) listHandouts(w http.ResponseWriter, r *http.Request) {
hs, err := s.st.Handouts(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "handouts", http.StatusOK, map[string]any{"Handouts": hs})
}
func (s *Server) createHandout(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
ctx := r.Context()
title := formText(r, "title")
if title == "" {
title = "Mein Übungsprogramm"
}
id, err := s.st.CreateHandout(ctx, title, formText(r, "recipient"))
if err != nil {
s.fail(w, r, err)
return
}
// Direkt mit einer Übung starten (Knopf auf der Übungsseite)
if eid, ok := formID(r, "entry"); ok {
if err := s.st.AddHandoutItem(ctx, id, eid); err != nil {
s.fail(w, r, err)
return
}
}
redirect(w, r, "/handout/%d", id)
}
func (s *Server) showHandout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
h, err := s.st.Handout(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
items, err := s.st.HandoutItems(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
exercises, err := s.st.ExercisesNotIn(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
pid, pname, err := s.st.HandoutPatient(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "handout", http.StatusOK, map[string]any{
"H": h, "Items": items, "Exercises": exercises,
"PatientID": pid, "PatientName": pname,
})
}
func (s *Server) updateHandout(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
h := store.Handout{ID: id, Title: formText(r, "title"), Recipient: formText(r, "recipient"), Notes: formText(r, "notes")}
if h.Title == "" {
h.Title = "Mein Übungsprogramm"
}
if err := s.st.UpdateHandout(r.Context(), h); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/handout/%d", id)
}
func (s *Server) deleteHandout(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if err := s.st.DeleteHandout(r.Context(), id); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/handouts")
}
func (s *Server) copyHandout(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
nid, err := s.st.CopyHandout(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/handout/%d", nid)
}
func (s *Server) addHandoutItem(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok1 := pathID(r, "id")
eid, ok2 := formID(r, "entry")
if !ok1 || !ok2 {
http.Error(w, "Bitte eine Übung auswählen", http.StatusBadRequest)
return
}
if err := s.st.AddHandoutItem(r.Context(), id, eid); err != nil {
s.fail(w, r, err)
return
}
if r.FormValue("from") == "entry" {
redirect(w, r, "/eintrag/%d?handout=%d", eid, id)
return
}
redirect(w, r, "/handout/%d", id)
}
func (s *Server) itemIDs(w http.ResponseWriter, r *http.Request) (int64, int64, bool) {
id, ok1 := pathID(r, "id")
eid, ok2 := pathID(r, "eid")
if !ok1 || !ok2 {
http.NotFound(w, r)
return 0, 0, false
}
return id, eid, true
}
func (s *Server) updateHandoutItem(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, eid, ok := s.itemIDs(w, r)
if !ok {
return
}
if err := s.st.UpdateHandoutItem(r.Context(), id, eid, formText(r, "dosage"), formText(r, "note")); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/handout/%d#u%d", id, eid)
}
func (s *Server) moveHandoutItem(w http.ResponseWriter, r *http.Request) {
id, eid, ok := s.itemIDs(w, r)
if !ok {
return
}
if err := s.st.MoveHandoutItemUp(r.Context(), id, eid); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/handout/%d#u%d", id, eid)
}
func (s *Server) removeHandoutItem(w http.ResponseWriter, r *http.Request) {
id, eid, ok := s.itemIDs(w, r)
if !ok {
return
}
if err := s.st.RemoveHandoutItem(r.Context(), id, eid); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/handout/%d", id)
}
func (s *Server) printHandout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
h, err := s.st.Handout(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
items, err := s.st.HandoutItems(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "print", http.StatusOK, map[string]any{"H": h, "Items": items, "Today": time.Now()})
}
// addToHandout: von der Übungsseite aus, Handout per Auswahlliste (ohne JS).
func (s *Server) addToHandout(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
hid, ok1 := formID(r, "handout")
eid, ok2 := formID(r, "entry")
if !ok1 || !ok2 {
http.Error(w, "Bitte Handout und Übung auswählen", http.StatusBadRequest)
return
}
if err := s.st.AddHandoutItem(r.Context(), hid, eid); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/eintrag/%d?handout=%d#handout", eid, hid)
}
▾Ainternal/web/media.go
@@ -0,0 +1,138 @@
package web
import (
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"forge.gpl-nas.de/physiobase/internal/store"
)
const maxUpload = 1 << 30 // 1 GB pro Upload (Videos)
var (
imageExt = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
videoExt = map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true}
)
func extOf(f string) string { return strings.ToLower(filepath.Ext(f)) }
func isImage(f string) bool { return imageExt[extOf(f)] }
func isVideo(f string) bool { return videoExt[extOf(f)] }
func allowedUpload(f string) bool {
e := extOf(f)
return imageExt[e] || videoExt[e] || e == ".pdf"
}
func (s *Server) uploadMedia(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if _, err := s.st.Entry(ctx, id); err != nil {
s.fail(w, r, err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
// Nur 32 MB im Speicher, der Rest landet in Temp-Dateien
if err := r.ParseMultipartForm(32 << 20); err != nil { //nolint:gosec // G120: Größe oben per MaxBytesReader begrenzt
http.Error(w, "Upload fehlgeschlagen (max. 1 GB)", http.StatusBadRequest)
return
}
defer func() { _ = r.MultipartForm.RemoveAll() }()
caption := formText(r, "caption")
for _, fh := range r.MultipartForm.File["files"] {
if !allowedUpload(fh.Filename) {
http.Error(w, "Dateityp nicht erlaubt: "+fh.Filename+" (Bilder, Videos, PDF)", http.StatusBadRequest)
return
}
name := fmt.Sprintf("%d-%s%s", id, randomHex(8), extOf(fh.Filename))
if err := s.saveUpload(fh.Open, name); err != nil {
s.fail(w, r, err)
return
}
c := caption
if c == "" {
c = strings.TrimSuffix(filepath.Base(fh.Filename), filepath.Ext(fh.Filename))
}
if err := s.st.AddMedia(ctx, store.Media{EntryID: id, Filename: name, Caption: c}); err != nil {
os.Remove(filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name vom Server erzeugt
s.fail(w, r, err)
return
}
}
redirect(w, r, "/eintrag/%d#medien", id)
}
// saveUpload schreibt erst in eine Temp-Datei und benennt dann um, damit nie
// halbe Dateien unter dem endgültigen Namen liegen.
func (s *Server) saveUpload(open func() (multipart.File, error), name string) error {
src, err := open()
if err != nil {
return err
}
defer func() { _ = src.Close() }()
tmp, err := os.CreateTemp(s.cfg.MediaDir, ".upload-*")
if err != nil {
return err
}
if _, err := io.Copy(tmp, src); err != nil {
_ = tmp.Close()
os.Remove(tmp.Name())
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmp.Name())
return err
}
// CreateTemp legt die Datei mit 0600 an; ausgeliefert wird sie nur über die App.
return os.Rename(tmp.Name(), filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name vom Server erzeugt
}
func (s *Server) deleteMedia(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
m, err := s.st.DeleteMedia(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
if err := os.Remove(filepath.Join(s.cfg.MediaDir, m.Filename)); err != nil && !errors.Is(err, os.ErrNotExist) {
s.log.Warn("medium nicht gelöscht", "datei", m.Filename, "err", err)
}
redirect(w, r, "/eintrag/%d#medien", m.EntryID)
}
func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
// Nur Dateien ausliefern, die in der Datenbank stehen – kein Pfad-Trick möglich.
if name != filepath.Base(name) || strings.HasPrefix(name, ".") {
http.NotFound(w, r)
return
}
known, err := s.st.MediaKnown(r.Context(), name)
if err != nil {
s.fail(w, r, err)
return
}
if !known {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "private, max-age=86400")
if extOf(name) == ".pdf" {
w.Header().Set("Content-Disposition", "inline")
}
// ServeFile unterstützt Range-Requests → Videos sind spulbar.
http.ServeFile(w, r, filepath.Join(s.cfg.MediaDir, name)) //nolint:gosec // G703: Name geprüft und in der DB bekannt
}
▾Ainternal/web/patients.go
@@ -0,0 +1,567 @@
package web
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"forge.gpl-nas.de/physiobase/internal/befund"
"forge.gpl-nas.de/physiobase/internal/store"
)
var isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
func validDate(s string) string {
if isoDate.MatchString(s) {
return s
}
return time.Now().Format("2006-01-02")
}
// ---------- Patientenliste ----------
func (s *Server) listPatients(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
archived := r.URL.Query().Get("archiv") == "1"
ps, err := s.st.Patients(r.Context(), q, archived)
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "patients", http.StatusOK, map[string]any{
"Patients": ps, "Q": q, "Archived": archived, "Imported": r.URL.Query().Get("importiert"),
})
}
func (s *Server) createPatient(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
p := store.Patient{Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone")}
if p.Name == "" {
http.Error(w, "Name fehlt", http.StatusBadRequest)
return
}
id, err := s.st.CreatePatient(r.Context(), p)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d", id)
}
// ---------- Patient ----------
func (s *Server) showPatient(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
p, err := s.st.Patient(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
bs, err := s.st.Befunde(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
sessions, err := s.st.Sessions(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
linked, err := s.st.PatientEntries(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
linkable, err := s.st.EntriesOfKind(ctx, "krankheitsbild", "protokoll", "notiz")
if err != nil {
s.fail(w, r, err)
return
}
handouts, err := s.st.HandoutsForPatient(ctx, id)
if err != nil {
s.fail(w, r, err)
return
}
// Verlauf der Kernwerte über alle Befunde (älteste zuerst)
type trendRow struct {
Date, Title string
Now, Worst, PSFS, Tests string
ID int64
}
var trend []trendRow
for i := len(bs) - 1; i >= 0; i-- {
d, _ := befund.Parse(bs[i].Data)
trend = append(trend, trendRow{
ID: bs[i].ID, Date: bs[i].Date, Title: bs[i].Title,
Now: d.Get("pain_now"), Worst: d.Get("pain_worst"), PSFS: befund.PSFSMean(d),
Tests: fmt.Sprint(len(d.Tables["measures"])),
})
}
s.render(w, r, "patient", http.StatusOK, map[string]any{
"P": p, "Befunde": bs, "Sessions": sessions, "Linked": linked, "Linkable": linkable,
"Handouts": handouts, "Trend": trend, "Today": time.Now().Format("2006-01-02"),
})
}
func (s *Server) updatePatient(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
p := store.Patient{ID: id, Name: formText(r, "name"), Dob: formText(r, "dob"), Phone: formText(r, "phone"), Notes: formText(r, "notes")}
if p.Name == "" {
http.Error(w, "Name fehlt", http.StatusBadRequest)
return
}
if err := s.st.UpdatePatient(r.Context(), p); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d", id)
}
func (s *Server) archivePatient(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if err := s.st.SetArchived(r.Context(), id, r.FormValue("archiv") == "1"); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d", id)
}
func (s *Server) deletePatient(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if err := s.st.DeletePatient(r.Context(), id); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patienten")
}
func (s *Server) addSession(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
x := store.Session{PatientID: id, Date: validDate(formText(r, "date")), Goal: formText(r, "goal"), Note: formText(r, "note")}
if x.Goal != "" || x.Note != "" {
if err := s.st.AddSession(r.Context(), x); err != nil {
s.fail(w, r, err)
return
}
}
redirect(w, r, "/patient/%d#verlauf", id)
}
func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
pid, err := s.st.DeleteSession(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d#verlauf", pid)
}
func (s *Server) linkPatientEntry(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok1 := pathID(r, "id")
eid, ok2 := formID(r, "entry")
if !ok1 || !ok2 {
http.Error(w, "Bitte einen Eintrag auswählen", http.StatusBadRequest)
return
}
if err := s.st.LinkPatientEntry(r.Context(), id, eid); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d#wissen", id)
}
func (s *Server) unlinkPatientEntry(w http.ResponseWriter, r *http.Request) {
id, ok1 := pathID(r, "id")
eid, ok2 := pathID(r, "eid")
if !ok1 || !ok2 {
http.NotFound(w, r)
return
}
if err := s.st.UnlinkPatientEntry(r.Context(), id, eid); err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d#wissen", id)
}
func (s *Server) createPatientHandout(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
p, err := s.st.Patient(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
title := formText(r, "title")
if title == "" {
title = "Heimübungsprogramm"
}
hid, err := s.st.CreateHandoutFor(r.Context(), title, p)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/handout/%d", hid)
}
// ---------- Befund ----------
// newBefund: leer oder als Kopie des letzten Befunds (Wiederbefund – nur Werte neu messen).
func (s *Server) newBefund(w http.ResponseWriter, r *http.Request) {
if !limitForm(w, r) {
return
}
ctx := r.Context()
pid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if _, err := s.st.Patient(ctx, pid); err != nil {
s.fail(w, r, err)
return
}
b := store.Befund{PatientID: pid, Date: time.Now().Format("2006-01-02"), Title: "Erstbefund"}
if src, ok := formID(r, "kopie"); ok {
old, err := s.st.Befund(ctx, src)
if err != nil || old.PatientID != pid {
http.Error(w, "Vorlage nicht gefunden", http.StatusBadRequest)
return
}
b.Data, b.Title = old.Data, "Wiederbefund"
}
if t := formText(r, "title"); t != "" {
b.Title = t
}
id, err := s.st.CreateBefund(ctx, b)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/befund/%d", id)
}
func (s *Server) loadBefund(w http.ResponseWriter, r *http.Request) (store.Befund, store.Patient, befund.Data, bool) {
ctx := r.Context()
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return store.Befund{}, store.Patient{}, befund.Data{}, false
}
b, err := s.st.Befund(ctx, id)
if err != nil {
s.fail(w, r, err)
return b, store.Patient{}, befund.Data{}, false
}
p, err := s.st.Patient(ctx, b.PatientID)
if err != nil {
s.fail(w, r, err)
return b, p, befund.Data{}, false
}
d, err := befund.Parse(b.Data)
if err != nil {
s.fail(w, r, err)
return b, p, d, false
}
return b, p, d, true
}
func (s *Server) showBefund(w http.ResponseWriter, r *http.Request) {
b, p, d, ok := s.loadBefund(w, r)
if !ok {
return
}
tests, err := s.st.EntriesOfKind(r.Context(), "test")
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "befund", http.StatusOK, map[string]any{
"B": b, "P": p, "D": d, "Sections": befund.Sections, "Symbols": befund.Symbols, "Views": befund.Views,
"Tests": tests, "Measures": befund.Measures, "Saved": r.URL.Query().Get("gespeichert") == "1",
})
}
func (s *Server) saveBefund(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
if err := r.ParseForm(); err != nil {
http.Error(w, "Formular ungültig oder zu groß", http.StatusBadRequest)
return
}
b, _, _, ok := s.loadBefund(w, r)
if !ok {
return
}
b.Date = validDate(formText(r, "date"))
b.Title = formText(r, "title")
if b.Title == "" {
b.Title = "Befund"
}
b.Data = befund.FromForm(r.PostForm).JSON()
if err := s.st.UpdateBefund(r.Context(), b); err != nil {
s.fail(w, r, err)
return
}
if r.FormValue("next") == "druck" {
redirect(w, r, "/befund/%d/druck", b.ID)
return
}
redirect(w, r, "/befund/%d?gespeichert=1", b.ID)
}
func (s *Server) deleteBefund(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
pid, err := s.st.DeleteBefund(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
redirect(w, r, "/patient/%d", pid)
}
func (s *Server) printBefund(w http.ResponseWriter, r *http.Request) {
b, p, d, ok := s.loadBefund(w, r)
if !ok {
return
}
var sessions []store.Session
if r.URL.Query().Get("verlauf") == "1" {
var err error
if sessions, err = s.st.Sessions(r.Context(), p.ID); err != nil {
s.fail(w, r, err)
return
}
}
s.render(w, r, "befund_print", http.StatusOK, map[string]any{
"B": b, "P": p, "D": d, "Sections": befund.Sections, "Views": befund.Views, "Sessions": sessions, "Today": time.Now(),
})
}
// Export im eigenen Format (lässt sich wieder importieren).
type exportFile struct {
Format string `json:"format"`
Patient exportPatient `json:"patient"`
Date string `json:"date"`
Title string `json:"title"`
Data json.RawMessage `json:"data"`
}
type exportPatient struct {
Name string `json:"name"`
Dob string `json:"dob"`
}
const exportFormat = "physiobase-befund-1"
func (s *Server) exportBefund(w http.ResponseWriter, r *http.Request) {
b, p, _, ok := s.loadBefund(w, r)
if !ok {
return
}
name := strings.Map(func(c rune) rune {
if c == ' ' || c == '/' || c == '\\' || c == '"' {
return '_'
}
return c
}, p.Name)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="Befund_%s_%s.json"`, name, b.Date))
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(exportFile{Format: exportFormat, Patient: exportPatient{p.Name, p.Dob}, Date: b.Date, Title: b.Title, Data: json.RawMessage(b.Data)}); err != nil {
s.log.Error("export", "befund", b.ID, "err", err)
}
}
// ---------- Vergleich ----------
func (s *Server) compareBefunde(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pid, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
p, err := s.st.Patient(ctx, pid)
if err != nil {
s.fail(w, r, err)
return
}
bs, err := s.st.Befunde(ctx, pid)
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{"P": p, "Befunde": bs}
if len(bs) >= 2 {
// Standard: ältester (Baseline) gegen neuesten
aID, bID := bs[len(bs)-1].ID, bs[0].ID
if v, ok := formID(r, "a"); ok {
aID = v
}
if v, ok := formID(r, "b"); ok {
bID = v
}
var A, B *store.Befund
for i := range bs {
if bs[i].ID == aID {
A = &bs[i]
}
if bs[i].ID == bID {
B = &bs[i]
}
}
if A != nil && B != nil {
da, _ := befund.Parse(A.Data)
db, _ := befund.Parse(B.Data)
fields, tables := befund.Compare(da, db)
data["A"], data["B"], data["Fields"], data["Tables"] = A, B, fields, tables
}
}
s.render(w, r, "compare", http.StatusOK, data)
}
// ---------- Import alter Befunde ----------
func (s *Server) importBefunde(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil { //nolint:gosec // G120: Größe oben per MaxBytesReader begrenzt
http.Error(w, "Upload fehlgeschlagen", http.StatusBadRequest)
return
}
defer func() { _ = r.MultipartForm.RemoveAll() }()
var count int
var errs []string
for _, fh := range r.MultipartForm.File["files"] {
f, err := fh.Open()
if err != nil {
errs = append(errs, fh.Filename+": "+err.Error())
continue
}
raw, err := io.ReadAll(io.LimitReader(f, 10<<20))
_ = f.Close()
if err != nil {
errs = append(errs, fh.Filename+": "+err.Error())
continue
}
if err := s.importOne(r, raw); err != nil {
errs = append(errs, fh.Filename+": "+err.Error())
continue
}
count++
}
if len(errs) > 0 {
s.log.Warn("import teilweise fehlgeschlagen", "fehler", errs)
ps, _ := s.st.Patients(ctx, "", false)
s.render(w, r, "patients", http.StatusBadRequest, map[string]any{
"Patients": ps, "Imported": fmt.Sprint(count), "ImportErrors": errs,
})
return
}
redirect(w, r, "/patienten?importiert=%d", count)
}
func (s *Server) importOne(r *http.Request, raw []byte) error {
ctx := r.Context()
var probe struct {
Format string `json:"format"`
}
_ = json.Unmarshal(raw, &probe) // kein eigenes Format → alte Befundseite
var name, dob, date, title string
var data befund.Data
var sessions []befund.Row
if probe.Format == exportFormat {
var ef exportFile
if err := json.Unmarshal(raw, &ef); err != nil {
return err
}
d, err := befund.Parse(string(ef.Data))
if err != nil {
return err
}
d.Stamps = befund.CleanStamps(d.Stamps)
name, dob, date, title, data = ef.Patient.Name, ef.Patient.Dob, ef.Date, ef.Title, d
} else {
l, err := befund.ParseLegacy(raw)
if err != nil {
return err
}
name, dob, date, title, data, sessions = l.Name, l.Dob, l.Date, "Befund (Import)", l.Data, l.Sessions
}
if strings.TrimSpace(name) == "" {
name = "Unbekannt (Import)"
}
pid, found, err := s.st.FindPatient(ctx, name, dob)
if err != nil {
return err
}
if !found {
if pid, err = s.st.CreatePatient(ctx, store.Patient{Name: name, Dob: dob}); err != nil {
return err
}
}
if _, err := s.st.CreateBefund(ctx, store.Befund{PatientID: pid, Date: validDate(date), Title: title, Data: data.JSON()}); err != nil {
return err
}
for _, x := range sessions {
if x["goal"] == "" && x["note"] == "" {
continue
}
if err := s.st.AddSession(ctx, store.Session{PatientID: pid, Date: validDate(x["date"]), Goal: x["goal"], Note: x["note"]}); err != nil {
return err
}
}
return nil
}
▾Ainternal/web/server.go
@@ -0,0 +1,283 @@
// Package web enthält Routen, Handler und Templates-Rendering.
package web
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
physiobase "forge.gpl-nas.de/physiobase"
"forge.gpl-nas.de/physiobase/internal/befund"
"forge.gpl-nas.de/physiobase/internal/config"
"forge.gpl-nas.de/physiobase/internal/store"
)
type Server struct {
cfg *config.Config
st *store.Store
log *slog.Logger
pages map[string]*template.Template
sessionMu sync.RWMutex
sessionSecret string
loginSem chan struct{}
}
func New(ctx context.Context, cfg *config.Config, st *store.Store, log *slog.Logger) (*Server, error) {
s := &Server{cfg: cfg, st: st, log: log, pages: map[string]*template.Template{}, loginSem: make(chan struct{}, maxConcurrentLogins)}
if err := s.loadSessionSecret(ctx); err != nil {
return nil, err
}
for _, p := range []string{"index", "entry", "edit", "handouts", "handout", "login", "patients", "patient", "befund", "compare"} {
t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/layout.html", "web/templates/"+p+".html")
if err != nil {
return nil, fmt.Errorf("template %s: %w", p, err)
}
s.pages[p] = t
}
for _, p := range []string{"print", "befund_print"} {
t, err := template.New("").Funcs(funcs).ParseFS(physiobase.TemplatesFS, "web/templates/"+p+".html")
if err != nil {
return nil, fmt.Errorf("template %s: %w", p, err)
}
s.pages[p] = t
}
return s, nil
}
// Handler: Auth gilt global, damit neue Routen automatisch geschützt sind.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
static, _ := fs.Sub(physiobase.StaticFS, "web/static")
mux.Handle("GET /static/", http.StripPrefix("/static/", cacheForever(http.FileServerFS(static))))
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.login)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("GET /eintrag/neu", s.newEntry)
mux.HandleFunc("POST /eintrag/neu", s.saveEntry)
mux.HandleFunc("GET /eintrag/{id}", s.showEntry)
mux.HandleFunc("GET /eintrag/{id}/bearbeiten", s.editEntry)
mux.HandleFunc("POST /eintrag/{id}/bearbeiten", s.saveEntry)
mux.HandleFunc("POST /eintrag/{id}/loeschen", s.deleteEntry)
mux.HandleFunc("POST /eintrag/{id}/verknuepfen", s.linkEntry)
mux.HandleFunc("POST /eintrag/{id}/entknuepfen/{other}", s.unlinkEntry)
mux.HandleFunc("POST /eintrag/{id}/quelle", s.addSource)
mux.HandleFunc("POST /quelle/{id}/loeschen", s.deleteSource)
mux.HandleFunc("POST /eintrag/{id}/medien", s.uploadMedia)
mux.HandleFunc("POST /medium/{id}/loeschen", s.deleteMedia)
mux.HandleFunc("GET /medien/{file}", s.serveMedia)
mux.HandleFunc("GET /handouts", s.listHandouts)
mux.HandleFunc("POST /handouts", s.createHandout)
mux.HandleFunc("POST /handouts/uebung", s.addToHandout)
mux.HandleFunc("GET /handout/{id}", s.showHandout)
mux.HandleFunc("POST /handout/{id}", s.updateHandout)
mux.HandleFunc("POST /handout/{id}/loeschen", s.deleteHandout)
mux.HandleFunc("POST /handout/{id}/kopieren", s.copyHandout)
mux.HandleFunc("POST /handout/{id}/uebung", s.addHandoutItem)
mux.HandleFunc("POST /handout/{id}/uebung/{eid}", s.updateHandoutItem)
mux.HandleFunc("POST /handout/{id}/uebung/{eid}/hoch", s.moveHandoutItem)
mux.HandleFunc("POST /handout/{id}/uebung/{eid}/loeschen", s.removeHandoutItem)
mux.HandleFunc("GET /handout/{id}/druck", s.printHandout)
mux.HandleFunc("GET /patienten", s.listPatients)
mux.HandleFunc("POST /patienten", s.createPatient)
mux.HandleFunc("POST /patienten/import", s.importBefunde)
mux.HandleFunc("GET /patient/{id}", s.showPatient)
mux.HandleFunc("POST /patient/{id}", s.updatePatient)
mux.HandleFunc("POST /patient/{id}/archiv", s.archivePatient)
mux.HandleFunc("POST /patient/{id}/loeschen", s.deletePatient)
mux.HandleFunc("POST /patient/{id}/befund", s.newBefund)
mux.HandleFunc("POST /patient/{id}/verlauf", s.addSession)
mux.HandleFunc("POST /verlauf/{id}/loeschen", s.deleteSession)
mux.HandleFunc("POST /patient/{id}/wissen", s.linkPatientEntry)
mux.HandleFunc("POST /patient/{id}/wissen/{eid}/loeschen", s.unlinkPatientEntry)
mux.HandleFunc("POST /patient/{id}/handout", s.createPatientHandout)
mux.HandleFunc("GET /patient/{id}/vergleich", s.compareBefunde)
mux.HandleFunc("GET /befund/{id}", s.showBefund)
mux.HandleFunc("POST /befund/{id}", s.saveBefund)
mux.HandleFunc("POST /befund/{id}/loeschen", s.deleteBefund)
mux.HandleFunc("GET /befund/{id}/druck", s.printBefund)
mux.HandleFunc("GET /befund/{id}/export", s.exportBefund)
// CrossOriginProtection (Go 1.25+) blockt fremde POSTs anhand von Sec-Fetch-Site/Origin – CSRF-Schutz ohne Tokens.
return s.securityHeaders(http.NewCrossOriginProtection().Handler(s.requireAuth(s.logRequests(mux))))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.st.Ping(ctx); err != nil {
http.Error(w, "false", http.StatusServiceUnavailable)
return
}
w.Write([]byte("true"))
}
// ---------- Middleware ----------
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
if s.cfg.IsHTTPS() {
h.Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r)
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(c int) { w.status = c; w.ResponseWriter.WriteHeader(c) }
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
if r.URL.Path != "/healthz" {
s.log.Debug("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dauer", time.Since(start))
}
})
}
func cacheForever(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
next.ServeHTTP(w, r)
})
}
// ---------- Rendering ----------
var md = goldmark.New(goldmark.WithExtensions(extension.GFM)) // ohne WithUnsafe: kein rohes HTML
var funcs = template.FuncMap{
"kindLabel": store.KindLabel,
"md": func(src string) template.HTML {
var b bytes.Buffer
if err := md.Convert([]byte(src), &b); err != nil {
return template.HTML(template.HTMLEscapeString(src)) //nolint:gosec // G203: bereits escaped
}
// goldmark ohne WithUnsafe: rohes HTML im Markdown wird verworfen.
return template.HTML(b.String()) //nolint:gosec // G203
},
"date": func(t time.Time) string { return t.Local().Format("02.01.2006") },
"add1": func(i int) int { return i + 1 },
// Diagnose eines Befunds für Listen
"diag": func(raw string) string { d, _ := befund.Parse(raw); return d.Get("diag") },
"cell": func(t, c string, i int) string { return fmt.Sprintf("t.%s.%d.%s", t, i, c) },
"fmtNum": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
"isoDate": func(t time.Time) string { return t.Local().Format("2006-01-02") },
"de": func(iso string) string { // 2025-03-01 → 01.03.2025
if t, err := time.Parse("2006-01-02", iso); err == nil {
return t.Format("02.01.2006")
}
return iso
},
"isImage": isImage,
"isVideo": isVideo,
"isPDF": func(f string) bool { return strings.EqualFold(extOf(f), ".pdf") },
"tags": func(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
},
// Für Handouts: höchstens zwei Bilder pro Übung
"images": func(m []store.Media) []store.Media {
var out []store.Media
for _, x := range m {
if isImage(x.Filename) {
if out = append(out, x); len(out) == 2 {
break
}
}
}
return out
},
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, status int, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Practice"] = s.cfg.PracticeName
data["Kinds"] = store.Kinds
data["Path"] = r.URL.Path
name := "layout"
if page == "print" || page == "befund_print" {
name = page
}
var buf bytes.Buffer
if err := s.pages[page].ExecuteTemplate(&buf, name, data); err != nil {
s.fail(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
buf.WriteTo(w)
}
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "Nicht gefunden", http.StatusNotFound)
return
}
s.log.Error("anfrage fehlgeschlagen", "method", r.Method, "path", r.URL.Path, "err", err)
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
}
func redirect(w http.ResponseWriter, r *http.Request, format string, args ...any) {
// Ziele sind immer feste, interne Pfade mit numerischen IDs.
http.Redirect(w, r, fmt.Sprintf(format, args...), http.StatusSeeOther) //nolint:gosec // G710
}
func pathID(r *http.Request, name string) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
return id, err == nil && id > 0
}
func formID(r *http.Request, name string) (int64, bool) {
id, err := strconv.ParseInt(r.FormValue(name), 10, 64)
return id, err == nil && id > 0
}
// formText: getrimmt, Zeilenenden vereinheitlicht.
func formText(r *http.Request, name string) string {
return strings.TrimSpace(strings.ReplaceAll(r.FormValue(name), "\r\n", "\n"))
}
// limitForm begrenzt normale Formulare (nicht Uploads) auf 2 MB.
func limitForm(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
if err := r.ParseForm(); err != nil {
http.Error(w, "Formular ungültig oder zu groß", http.StatusBadRequest)
return false
}
return true
}
▾Ainternal/web/web_test.go
@@ -0,0 +1,219 @@
package web
import (
"context"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"forge.gpl-nas.de/physiobase/internal/config"
"forge.gpl-nas.de/physiobase/internal/database"
"forge.gpl-nas.de/physiobase/internal/store"
)
func newServer(t *testing.T) (*httptest.Server, *http.Client) {
t.Helper()
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close(db) })
hash, _ := HashPassword("geheim123")
cfg := &config.Config{Username: "admin", PasswordHash: hash, PracticeName: "Test", MediaDir: dir}
st := store.New(db)
if _, err := st.Seed(context.Background()); err != nil {
t.Fatal(err)
}
srv, err := New(context.Background(), cfg, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
return ts, c
}
func post(c *http.Client, u string, v url.Values) (*http.Response, error) {
req, _ := http.NewRequest(http.MethodPost, u, strings.NewReader(v.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Sec-Fetch-Site", "same-origin")
return c.Do(req)
}
func body(t *testing.T, c *http.Client, u string) (int, string) {
t.Helper()
r, err := c.Get(u)
if err != nil {
t.Fatal(err)
}
defer r.Body.Close()
b, _ := io.ReadAll(r.Body)
return r.StatusCode, string(b)
}
func TestAuthFlow(t *testing.T) {
ts, c := newServer(t)
if code, _ := body(t, c, ts.URL+"/"); code != http.StatusSeeOther {
t.Fatalf("ohne Anmeldung: %d, erwartet Redirect", code)
}
if code, _ := body(t, c, ts.URL+"/healthz"); code != 200 {
t.Fatalf("healthz: %d", code)
}
r, _ := post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"falsch"}})
if r.StatusCode != http.StatusUnauthorized {
t.Fatalf("falsches Passwort: %d", r.StatusCode)
}
r, _ = post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"geheim123"}})
if r.StatusCode != http.StatusSeeOther {
t.Fatalf("login: %d", r.StatusCode)
}
code, html := body(t, c, ts.URL+"/?q=knie")
if code != 200 || !strings.Contains(html, "Patellatendinopathie") {
t.Fatalf("Suche nach Login: %d", code)
}
// Fremde Seite darf nichts posten (CSRF)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/eintrag/1/loeschen", nil)
req.Header.Set("Sec-Fetch-Site", "cross-site")
r, _ = c.Do(req)
if r.StatusCode != http.StatusForbidden {
t.Fatalf("cross-site POST: %d, erwartet 403", r.StatusCode)
}
// Abmelden macht das alte Cookie ungültig
post(c, ts.URL+"/logout", nil)
if code, _ := body(t, c, ts.URL+"/"); code != http.StatusSeeOther {
t.Fatal("nach Abmelden noch angemeldet")
}
}
func TestPagesRender(t *testing.T) {
ts, c := newServer(t)
post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"geheim123"}})
for _, p := range []string{"/", "/eintrag/1", "/eintrag/3", "/eintrag/1/bearbeiten", "/eintrag/neu?art=uebung", "/handouts", "/handout/1", "/handout/1/druck"} {
if code, html := body(t, c, ts.URL+p); code != 200 || !strings.Contains(html, "</html>") {
t.Errorf("%s: %d", p, code)
}
}
if code, _ := body(t, c, ts.URL+"/eintrag/9999"); code != 404 {
t.Errorf("unbekannter Eintrag: %d", code)
}
if code, _ := body(t, c, ts.URL+"/medien/..%2f..%2fetc%2fpasswd"); code != 404 {
t.Errorf("Pfad-Trick: %d", code)
}
// Neuer Eintrag, verknüpft angelegt
r, _ := post(c, ts.URL+"/eintrag/neu", url.Values{"kind": {"test"}, "title": {"Lachman-Test"}, "region": {"Knie"}, "link_from": {"1"}})
if r.StatusCode != http.StatusSeeOther {
t.Fatalf("anlegen: %d", r.StatusCode)
}
if _, html := body(t, c, ts.URL+"/eintrag/1"); !strings.Contains(html, "Lachman-Test") {
t.Error("neuer Eintrag nicht verknüpft")
}
if _, html := body(t, c, ts.URL+"/handout/1/druck"); !strings.Contains(html, "Wandsitz") {
t.Error("Handout-Druck ohne Übung")
}
}
func TestPatientBefundFlow(t *testing.T) {
ts, c := newServer(t)
post(c, ts.URL+"/login", url.Values{"username": {"admin"}, "password": {"geheim123"}})
r, _ := post(c, ts.URL+"/patienten", url.Values{"name": {"Max Test"}, "dob": {"1980"}})
if r.StatusCode != http.StatusSeeOther || r.Header.Get("Location") != "/patient/1" {
t.Fatalf("Patient anlegen: %d %s", r.StatusCode, r.Header.Get("Location"))
}
r, _ = post(c, ts.URL+"/patient/1/befund", url.Values{})
if loc := r.Header.Get("Location"); loc != "/befund/1" {
t.Fatalf("Befund anlegen: %s", loc)
}
if code, html := body(t, c, ts.URL+"/befund/1"); code != 200 || !strings.Contains(html, "Red Flags") || !strings.Contains(html, `name="t.rom.n"`) {
t.Fatalf("Befundformular: %d", code)
}
r, _ = post(c, ts.URL+"/befund/1", url.Values{
"title": {"Erstbefund"}, "date": {"2025-01-10"},
"f.diag": {"Lumbalgie"}, "f.pain_now": {"7"}, "t.rom.n": {"1"}, "t.rom.0.joint": {"LWS"}, "t.rom.0.move": {"Flex"}, "t.rom.0.act": {"40"},
"stamps": {`[{"v":1,"x":0.5,"y":0.45,"s":"pain","c":"#d32f2f","z":2}]`},
})
if r.StatusCode != http.StatusSeeOther {
t.Fatalf("Befund speichern: %d", r.StatusCode)
}
// Wiederbefund als Kopie, dann Wert ändern und vergleichen
r, _ = post(c, ts.URL+"/patient/1/befund", url.Values{"kopie": {"1"}})
if loc := r.Header.Get("Location"); loc != "/befund/2" {
t.Fatalf("Wiederbefund: %s", loc)
}
if _, html := body(t, c, ts.URL+"/befund/2"); !strings.Contains(html, "Lumbalgie") || !strings.Contains(html, "Wiederbefund") {
t.Fatal("Kopie hat Daten nicht übernommen")
}
post(c, ts.URL+"/befund/2", url.Values{
"title": {"Wiederbefund"}, "date": {"2025-02-10"}, "f.diag": {"Lumbalgie"}, "f.pain_now": {"3"},
"t.rom.n": {"1"}, "t.rom.0.joint": {"LWS"}, "t.rom.0.move": {"Flex"}, "t.rom.0.act": {"55"},
})
code, html := body(t, c, ts.URL+"/patient/1/vergleich")
if code != 200 || !strings.Contains(html, "&#43;15") || !strings.Contains(html, "NRS aktuell") {
t.Fatalf("Vergleich: %d\n%s", code, html[strings.Index(html, "<main"):])
}
if _, html := body(t, c, ts.URL+"/befund/1/druck"); !strings.Contains(html, "Lumbalgie") || !strings.Contains(html, "<text") || strings.Contains(html, "Fachbereich") {
t.Error("Druckansicht unvollständig")
}
// Verlauf, Handout, Wissen
post(c, ts.URL+"/patient/1/verlauf", url.Values{"date": {"2025-01-12"}, "goal": {"Bewegungsangst reduzieren"}, "note": {"gut"}})
post(c, ts.URL+"/patient/1/wissen", url.Values{"entry": {"1"}})
r, _ = post(c, ts.URL+"/patient/1/handout", url.Values{"title": {"Woche 1"}})
if !strings.HasPrefix(r.Header.Get("Location"), "/handout/") {
t.Fatal("Handout für Patient")
}
_, html = body(t, c, ts.URL+"/patient/1")
for _, want := range []string{"Bewegungsangst", "Woche 1", "Patellatendinopathie", "Verlauf der Kernwerte"} {
if !strings.Contains(html, want) {
t.Errorf("Patientenseite ohne %q", want)
}
}
// Export → Import ergibt denselben Patienten mit drittem Befund
resp, _ := c.Get(ts.URL + "/befund/2/export")
exp, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if code := importFiles(t, c, ts.URL, exp); code != http.StatusSeeOther {
t.Fatalf("Import Export-Datei: %d", code)
}
if _, html := body(t, c, ts.URL+"/patient/1"); strings.Count(html, `href="/befund/`) < 3 {
t.Error("Import nicht beim selben Patienten")
}
// Alte Befundseite
old := `{"name":"Erika Alt","date":"2024-12-01","diag":"Schulter","rom":[{"joint":"Schulter","move":"Abd","act":"90","pass":"120","end":""}],"treat":[{"date":"2024-12-02","goal":"Mobilisation","note":""}],"_v":2}`
if code := importFiles(t, c, ts.URL, []byte(old)); code != http.StatusSeeOther {
t.Fatalf("Import alt: %d", code)
}
if _, html := body(t, c, ts.URL+"/patienten"); !strings.Contains(html, "Erika Alt") {
t.Error("importierter Patient fehlt")
}
}
func importFiles(t *testing.T, c *http.Client, base string, files ...[]byte) int {
t.Helper()
var buf strings.Builder
w := multipart.NewWriter(&buf)
for i, f := range files {
fw, _ := w.CreateFormFile("files", fmt.Sprintf("Befund_%d.json", i))
fw.Write(f)
}
w.Close()
req, _ := http.NewRequest(http.MethodPost, base+"/patienten/import", strings.NewReader(buf.String()))
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
r, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
r.Body.Close()
return r.StatusCode
}
▾Aweb/static/befund-print.css
@@ -0,0 +1,32 @@
@page { size: A4; margin: 14mm 13mm; }
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 10.5pt; line-height: 1.4;
color: #111; max-width: 190mm; margin: 0 auto; padding: 1rem; background: #fff; }
.hint { background: #fff8d6; border: 1px solid #e6d27a; padding: .5rem .8rem; border-radius: 6px; font-size: 10pt; }
header { display: flex; align-items: center; gap: 1rem; border-bottom: 2px solid #2c6fbb; padding-bottom: .5rem; margin-bottom: .8rem; }
.logo { height: 16mm; width: auto; }
.htext { flex: 1; }
h1 { font-size: 16pt; margin: 0; }
header p { margin: .2rem 0 0; font-size: 11.5pt; }
.meta { text-align: right; font-size: 9.5pt; color: #444; }
section { margin-bottom: .6rem; }
h2 { font-size: 12pt; margin: .9rem 0 .3rem; padding: .15rem .5rem; background: #eef3f9; border-left: 4px solid #2c6fbb; break-after: avoid; }
h3 { font-size: 10.5pt; margin: .5rem 0 .2rem; color: #2c6fbb; break-after: avoid; }
dl { margin: 0; display: grid; grid-template-columns: 52mm 1fr; gap: .15rem .6rem; }
dt { font-weight: 600; color: #333; }
dd { margin: 0; white-space: pre-wrap; }
dt.chk { grid-column: 1 / -1; font-weight: 500; }
table { width: 100%; border-collapse: collapse; margin: .2rem 0 .4rem; font-size: 9.5pt; break-inside: auto; }
th, td { border: 1px solid #bbb; padding: .2rem .35rem; text-align: left; vertical-align: top; white-space: pre-wrap; }
th { background: #f3f3f3; }
tr { break-inside: avoid; }
.nowrap { white-space: nowrap; }
.bodies { display: flex; gap: 6mm; align-items: flex-end; break-inside: avoid; }
.bodies figure { margin: 0; width: 30mm; text-align: center; font-size: 8.5pt; color: #555; }
.body-canvas { position: relative; }
.body-canvas img { display: block; width: 100%; height: auto; }
.body-canvas svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.legend { list-style: none; padding: 0; margin: 0; font-size: 9pt; }
footer { display: flex; gap: 12mm; margin-top: 16mm; break-inside: avoid; }
.sig { flex: 1; border-top: 1px solid #333; padding-top: .2rem; font-size: 9pt; color: #444; }
@media print { .hint { display: none; } body { padding: 0; } }
▾Aweb/static/befund.js
@@ -0,0 +1,84 @@
// Befund: Körperschema stempeln, Tabellenzeilen ergänzen, Warnung bei ungespeicherten Änderungen.
// Ohne JavaScript funktioniert alles außer dem Stempeln (Formular, Tabellen per Speichern).
(() => {
"use strict";
document.documentElement.classList.add("js");
const form = document.getElementById("befund-form");
if (!form) return;
let dirty = false;
form.addEventListener("input", () => { dirty = true; });
form.addEventListener("submit", () => { dirty = false; });
window.addEventListener("beforeunload", (e) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } });
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); dirty = false; form.requestSubmit(); }
});
// ---- Tabellen: weitere Zeile ----
form.querySelectorAll("button.addrow").forEach((btn) => btn.addEventListener("click", () => {
const key = btn.dataset.table;
const tbody = form.querySelector(`table[data-table="${key}"] tbody`);
const count = form.querySelector(`input[name="t.${key}.n"]`);
if (!tbody || !count || !tbody.lastElementChild) return;
const n = parseInt(count.value, 10) || 0;
const tr = tbody.lastElementChild.cloneNode(true);
tr.querySelectorAll("input,select").forEach((el) => {
el.name = el.name.replace(/^(t\.[^.]+\.)\d+(\.)/, `$1${n}$2`);
if (el.tagName === "SELECT") el.selectedIndex = 0; else el.value = "";
});
tbody.appendChild(tr);
count.value = n + 1;
tr.querySelector("input,select")?.focus();
}));
// ---- Körperschema ----
const hidden = document.getElementById("stamps");
if (!hidden) return;
let stamps = [];
try { stamps = JSON.parse(hidden.value || "[]") || []; } catch { stamps = []; }
const symButtons = [...document.querySelectorAll(".body-tools .sym")];
const chars = Object.fromEntries(symButtons.map((b) => [b.dataset.sym, b.textContent]));
let sym = symButtons.find((b) => b.classList.contains("active"))?.dataset.sym || "pain";
symButtons.forEach((b) => b.addEventListener("click", () => {
symButtons.forEach((x) => x.classList.remove("active"));
b.classList.add("active");
sym = b.dataset.sym;
}));
const color = document.getElementById("stamp-color");
const size = document.getElementById("stamp-size");
const SIZES = [0, 7, 11, 16]; // gleich wie befund.StampSize
const NS = "http://www.w3.org/2000/svg";
const draw = (svg, s) => {
const t = document.createElementNS(NS, "text");
t.setAttribute("x", (s.x * +svg.dataset.w).toFixed(1));
t.setAttribute("y", (s.y * +svg.dataset.h).toFixed(1));
t.setAttribute("font-size", SIZES[s.z] || 11);
t.setAttribute("fill", s.c);
t.setAttribute("text-anchor", "middle");
t.setAttribute("dominant-baseline", "central");
t.textContent = chars[s.s] || "?";
svg.appendChild(t);
};
const save = () => { hidden.value = JSON.stringify(stamps); dirty = true; };
document.querySelectorAll("svg[data-view]").forEach((svg) => svg.addEventListener("pointerdown", (e) => {
e.preventDefault();
const r = svg.getBoundingClientRect();
const s = {
v: +svg.dataset.view,
x: +Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)).toFixed(4),
y: +Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)).toFixed(4),
s: sym, c: color?.value || "#d32f2f", z: +(size?.value || 2),
};
stamps.push(s);
draw(svg, s);
save();
}));
document.getElementById("stamp-undo")?.addEventListener("click", () => {
const s = stamps.pop();
if (!s) return;
document.querySelector(`svg[data-view="${s.v}"]`)?.lastElementChild?.remove();
save();
});
})();
▾Aweb/static/body/body-back.png
Bin 0 → 9887 bytes@ 967d8e2@ master
Binary file — not shown
▾Aweb/static/body/body-front.png
Bin 0 → 9990 bytes@ 967d8e2@ master
Binary file — not shown
▾Aweb/static/body/body-side.png
Bin 0 → 10322 bytes@ 967d8e2@ master
Binary file — not shown
▾Aweb/static/logo.webp
Bin 0 → 4392 bytes@ 967d8e2@ master
Binary file — not shown
▾Aweb/static/pico.min.css
@@ -0,0 +1,4 @@
@charset "UTF-8";/*!
* Pico CSS ✨ v2.1.1 (https://picocss.com)
* Copyright 2019-2025 - Licensed under MIT
*/:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
▾Aweb/static/print.css
@@ -0,0 +1,24 @@
/* Handout: A4, schlicht, gut lesbar für Patienten */
@page { size: A4; margin: 15mm 14mm; }
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12pt; line-height: 1.45;
color: #111; max-width: 190mm; margin: 0 auto; padding: 1rem; background: #fff; }
.hint { background: #fff8d6; border: 1px solid #e6d27a; padding: .5rem .8rem; border-radius: 6px; font-size: 10pt; }
header { display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 2px solid #16a085; padding-bottom: .4rem; margin-bottom: .8rem; }
h1 { font-size: 20pt; margin: 0; }
.for { margin: .2rem 0 0; font-size: 13pt; }
.meta { text-align: right; font-size: 10pt; color: #555; }
.notes { background: #f2f7f6; border-left: 4px solid #16a085; padding: .3rem .8rem; margin-bottom: .8rem; }
.ex { break-inside: avoid; page-break-inside: avoid; border: 1px solid #ccc; border-radius: 8px; padding: .6rem .9rem; margin-bottom: .7rem; }
.ex h2 { font-size: 14pt; margin: 0 0 .4rem; display: flex; align-items: center; gap: .5rem; }
.num { display: inline-flex; align-items: center; justify-content: center; width: 1.6em; height: 1.6em; border-radius: 50%;
background: #16a085; color: #fff; font-size: 11pt; }
.ex-body { display: flex; gap: .9rem; }
.pics { flex: 0 0 38%; display: flex; flex-direction: column; gap: .4rem; }
.pics img { width: 100%; max-height: 55mm; object-fit: contain; border-radius: 4px; }
.text { flex: 1; }
.text p, .text ul, .text ol { margin: .2rem 0 .4rem; }
.dose { font-weight: 700; font-size: 13pt; margin-top: .5rem; padding: .3rem .6rem; background: #f2f7f6; border-radius: 4px; display: inline-block; }
.note { font-style: italic; }
footer { margin-top: 1rem; font-size: 9.5pt; color: #555; border-top: 1px solid #ccc; padding-top: .4rem; }
@media print { .hint { display: none; } body { padding: 0; } }
▾Aweb/static/style.css
@@ -0,0 +1,199 @@
/* Ergänzungen zu Pico CSS – kein Inline-CSS (CSP style-src 'self') */
:root { --pico-font-size: 106.25%; }
@media (max-width: 576px) { :root { --pico-font-size: 100%; } }
.brand { text-decoration: none; }
.muted { color: var(--pico-muted-color); }
.error { color: var(--pico-del-color); font-weight: 600; }
.ok { color: var(--pico-ins-color); font-weight: 600; }
.narrow { max-width: 26rem; margin: 3rem auto; }
.mono { font-family: var(--pico-font-family-monospace); font-size: .9em; }
form.inline { display: inline; margin: 0; }
form.inline button { margin: 0; }
button.small { padding: .25rem .6rem; font-size: .85em; margin: 0; }
button.link { background: none; border: none; padding: 0 .3rem; margin: 0; width: auto; color: var(--pico-muted-color); cursor: pointer; }
button.link:hover { color: var(--pico-del-color); }
/* Formularzeilen: auf breiten Bildschirmen nebeneinander */
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: flex-start; }
.row > * { margin-bottom: .5rem; }
.row > input, .row > select { flex: 1 1 12rem; }
.row > button { flex: 0 0 auto; width: auto; }
.actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.actions > * { margin-bottom: 0; }
.spaced { margin-top: 2rem; }
.search { display: flex; gap: .5rem; }
.search input { flex: 1; margin: 0; }
.search button { width: auto; margin: 0; }
/* Filter-Chips */
.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin: 1rem 0 .5rem; }
.chips a { padding: .25rem .75rem; border-radius: 999px; border: 1px solid var(--pico-muted-border-color); text-decoration: none; font-size: .9em; }
.chips a[aria-current] { background: var(--pico-primary-background); color: var(--pico-primary-inverse); border-color: var(--pico-primary-background); }
.chips small { opacity: .7; }
.chips.regions a { font-size: .8em; }
/* Karten */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: .9rem; margin-top: 1rem; }
.card { display: flex; flex-direction: column; gap: .3rem; padding: 1rem; border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color); box-shadow: var(--pico-card-box-shadow); text-decoration: none; color: inherit; }
.card:hover { outline: 2px solid var(--pico-primary-border); }
.card strong { color: var(--pico-primary); }
.card .summary { font-size: .88em; color: var(--pico-muted-color); }
/* Art-Kennzeichnung */
.kind { display: inline-block; font-size: .72em; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
padding: .1rem .5rem; border-radius: .3rem; color: #fff; background: #607d8b; }
.kind-krankheitsbild { background: #c0392b; }
.kind-test { background: #8e44ad; }
.kind-uebung { background: #16a085; }
.kind-protokoll { background: #2c6fbb; }
.kind-notiz { background: #7f8c8d; }
.region { font-size: .8em; color: var(--pico-muted-color); }
.lead { font-size: 1.1em; }
.tags a { font-size: .85em; margin-right: .3rem; }
.dosage { padding: .6rem 1rem; border-left: 4px solid #16a085; background: var(--pico-code-background-color); }
.patient { border-top: 1px dashed var(--pico-muted-border-color); margin-top: 1.5rem; padding-top: 1rem; }
.prose table { font-size: .92em; }
.links li { list-style: none; }
.links { padding-left: 0; }
.new-linked a { margin-right: .6rem; font-size: .9em; }
fieldset.exercise { border: 1px dashed var(--pico-muted-border-color); padding: 1rem; border-radius: var(--pico-border-radius); }
/* Medien */
.media { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: .8rem; margin-bottom: 1rem; }
.media figure { margin: 0; }
.media img, .media video { width: 100%; max-height: 14rem; object-fit: cover; border-radius: var(--pico-border-radius); }
.media .file { display: block; padding: 2rem 1rem; text-align: center; border: 1px dashed var(--pico-muted-border-color); border-radius: var(--pico-border-radius); }
.media figcaption { font-size: .85em; }
/* Handout-Editor */
.item { padding: .8rem 1rem; margin-bottom: .6rem; }
.item-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: .4rem; }
/* Löschen */
details.danger summary { color: var(--pico-del-color); font-size: .9em; }
button.danger { background: var(--pico-del-color); border-color: var(--pico-del-color); width: auto; }
/* ---------- JS-abhängige Elemente ---------- */
.js-only { display: none !important; }
.js .js-only { display: inline-flex !important; }
.js .nojs-only { display: none; }
/* ---------- Patienten ---------- */
table.list td, table.list th { vertical-align: top; }
.nowrap { white-space: nowrap; }
.tablewrap { overflow-x: auto; }
.patient-head h1 { margin-bottom: .2rem; }
.session-form { display: grid; grid-template-columns: 10rem 1fr; gap: .5rem; }
.session-form textarea { grid-column: 1 / -1; margin: 0; }
.session-form input { margin: 0; }
.session-form button { grid-column: 1 / -1; width: auto; justify-self: start; }
@media (max-width: 576px) { .session-form { grid-template-columns: 1fr; } }
.session { border-left: 3px solid var(--pico-primary-border); padding: .3rem .8rem; margin: .6rem 0; }
.session-note { white-space: pre-wrap; color: var(--pico-muted-color); font-size: .92em; }
td.pre { white-space: pre-wrap; }
td.delta { font-weight: 700; white-space: nowrap; }
/* ---------- Befund ---------- */
.befund-head { display: grid; grid-template-columns: 1fr 14rem 11rem; gap: .8rem; align-items: end; }
@media (max-width: 768px) { .befund-head { grid-template-columns: 1fr 1fr; } .befund-head > div { grid-column: 1 / -1; } }
.toc { display: flex; flex-wrap: wrap; gap: .3rem; margin: .5rem 0 1rem; font-size: .8em; }
.toc a { padding: .15rem .55rem; border-radius: 999px; border: 1px solid var(--pico-muted-border-color); text-decoration: none; }
.toc a.filled { border-color: var(--pico-primary-border); background: var(--pico-primary-focus); }
.sec { margin: 1.4rem 0; padding: 1rem 1.2rem; border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color); box-shadow: var(--pico-card-box-shadow); scroll-margin-top: 1rem; }
.sec > h2, .sec > summary { font-size: 1.25rem; margin-bottom: .6rem; }
details.specialty > summary { font-weight: 700; }
.part { margin: .6rem 0 1rem; }
.part h4, details.part > summary { font-size: 1rem; color: var(--pico-primary); margin: .6rem 0 .4rem; }
.fgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 0 1rem; }
.fld.wide { grid-column: 1 / -1; }
.fld label { font-size: .88em; margin-bottom: .15rem; }
.fld textarea, .fld input, .fld select { margin-bottom: .7rem; }
.fld.chk label { font-size: .95em; }
.fld.chk { padding: .2rem 0; }
.fgrid:has(.chk) { grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr)); }
table.etable { margin-bottom: .3rem; }
table.etable td { padding: .15rem; }
table.etable input, table.etable select { margin: 0; padding: .35rem .5rem; height: auto; font-size: .92em; }
table.etable th { font-size: .82em; padding: .3rem; }
.savebar { position: sticky; bottom: 0; z-index: 5; display: flex; gap: .7rem; align-items: center; flex-wrap: wrap;
padding: .6rem 1rem; margin-top: 1rem; background: var(--pico-background-color);
border-top: 1px solid var(--pico-muted-border-color); }
.savebar button { width: auto; margin: 0; }
/* Körperschema */
.body-tools { flex-wrap: wrap; gap: .35rem; align-items: center; margin-bottom: .6rem; }
.body-tools .sym { width: auto; min-width: 2.6rem; padding: .25rem .5rem; margin: 0; background: transparent;
color: var(--pico-h2-color); border: 1px solid var(--pico-muted-border-color); font-size: 1.05rem; }
.body-tools .sym.active { color: var(--pico-h1-color); }
.body-tools .sym.active { border-color: var(--pico-primary); background: var(--pico-primary-focus); }
.body-tools input[type=color] { width: 2.6rem; height: 2.3rem; padding: 0; margin: 0; }
.body-tools select { width: auto; margin: 0; padding: .3rem 2rem .3rem .6rem; }
.body-tools button { margin: 0; width: auto; }
.bodies { display: flex; gap: 1.2rem; flex-wrap: wrap; }
.body { margin: 0; width: 9.5rem; text-align: center; }
.body-canvas { position: relative; background: #fff; border-radius: .4rem; }
.body-canvas img { display: block; width: 100%; height: auto; }
.body-canvas svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; cursor: crosshair; touch-action: manipulation; }
.legend { font-size: .82em; color: var(--pico-muted-color); display: flex; flex-wrap: wrap; gap: .3rem 1rem; margin-top: .4rem; }
/* ---------- Smartphone ---------- */
.site-footer { padding-top: 0; padding-bottom: 5rem; text-align: right; font-size: .85em; }
@media (max-width: 576px) {
:root { --pico-spacing: .75rem; }
body > header.container { padding-top: .25rem; padding-bottom: 0; }
body > header nav { flex-wrap: wrap; }
body > header nav > ul:first-child { width: 100%; }
body > header nav > ul:last-child { width: 100%; justify-content: space-between; margin: 0; }
body > header nav > ul:last-child > li { padding: .35rem .2rem; font-size: .95em; }
body > header nav details.dropdown summary { padding: .3rem .6rem; height: auto; }
}
/* Checkbox-Text neben der Box, Folgezeilen eingerückt */
.fld.chk label { display: flex; gap: .5rem; align-items: flex-start; }
.fld.chk input[type=checkbox] { flex: 0 0 auto; margin-top: .15rem; }
@media (max-width: 576px) {
.hide-sm { display: none !important; }
h1 { font-size: 1.6rem; }
nav[aria-label=breadcrumb] li:last-child { display: none; }
nav[aria-label=breadcrumb] { font-size: .9em; margin-bottom: .3rem; }
article, .sec { padding: .8rem .75rem; margin-bottom: 1rem; }
table.list { font-size: .88em; }
table.list th, table.list td { padding: .35rem .3rem; }
/* Formularzeilen: Eingaben volle Breite, Knöpfe darunter */
.row > input, .row > select { flex-basis: 100%; }
.row > button { flex: 1 1 auto; }
.actions > * { flex: 1 1 auto; }
.actions > form.inline button, .actions > a[role=button] { width: 100%; }
.compare-pick .arrow { display: none; }
/* Befund kompakter */
.befund-head { gap: 0 .6rem; }
.befund input:not([type=checkbox]):not([type=color]), .befund select, .befund textarea {
padding: .45rem .6rem; font-size: .95rem; margin-bottom: .5rem; }
.befund select { padding-right: 2rem; }
.befund textarea { min-height: 3.2rem; }
.fld label { margin-bottom: .1rem; }
/* Abschnitts-Sprungmarken als eine wischbare Zeile */
.toc { flex-wrap: nowrap; overflow-x: auto; padding-bottom: .4rem; margin: .2rem 0 .6rem; scrollbar-width: thin; }
.toc a { white-space: nowrap; flex: 0 0 auto; }
/* Kleine Zahlenfelder (NRS) zu dritt nebeneinander */
.fgrid { grid-template-columns: repeat(6, 1fr); }
.fgrid > .fld { grid-column: span 6; }
.fgrid > .fld.small { grid-column: span 2; }
.fgrid:has(.chk) { grid-template-columns: 1fr; }
.fgrid:has(.chk) > .fld { grid-column: auto; }
.fld.small label { font-size: .78em; min-height: 2.1em; }
/* Körperschema: drei Ansichten nebeneinander */
.bodies { flex-wrap: nowrap; gap: .5rem; justify-content: space-between; }
.body { width: auto; flex: 1 1 0; }
.body-tools .sym { min-width: 2.3rem; padding: .2rem .35rem; }
/* Speicherleiste schmal */
.savebar { padding: .4rem .5rem; gap: .4rem; margin: 1rem -.75rem 0; }
.savebar button { flex: 1 1 auto; padding: .5rem .6rem; font-size: .95rem; }
.etable input, .etable select { min-width: 6.5rem; }
}
▾Aweb/templates/befund.html
@@ -0,0 +1,113 @@
{{define "title"}}{{.B.Title}} – {{.P.Name}}{{end}}
{{define "content"}}
{{$d := .D}}
<nav aria-label="breadcrumb"><ul>
<li><a href="/patienten">Patienten</a></li>
<li><a href="/patient/{{.P.ID}}">{{.P.Name}}</a></li>
<li>{{.B.Title}} {{de .B.Date}}</li>
</ul></nav>
{{if .Saved}}<p class="ok" role="status">Gespeichert.</p>{{end}}
<form method="post" action="/befund/{{.B.ID}}" id="befund-form" class="befund">
<div class="befund-head">
<div><strong>{{.P.Name}}</strong>{{with .P.Dob}} · geb. {{.}}{{end}}</div>
<label>Titel <input name="title" value="{{.B.Title}}" list="dl-titles"></label>
<label>Datum <input type="date" name="date" value="{{.B.Date}}"></label>
</div>
<datalist id="dl-titles"><option value="Erstbefund"><option value="Wiederbefund"><option value="Zwischenbefund"><option value="Abschlussbefund"></datalist>
<datalist id="dl-tests">{{range .Tests}}<option value="{{.Title}}">{{end}}</datalist>
<datalist id="dl-measures">{{range .Measures}}<option value="{{.}}">{{end}}</datalist>
<nav class="toc" aria-label="Abschnitte – springen zu">
{{range .Sections}}<a href="#s-{{.ID}}"{{if $d.HasSection .}} class="filled"{{end}}>{{.Title}}</a>{{end}}
</nav>
{{range $s := .Sections}}
{{if $s.Specialty}}<details class="sec specialty" id="s-{{$s.ID}}"{{if $d.HasSection $s}} open{{end}}><summary>Fachbereich: {{$s.Title}}</summary>
{{else}}<section class="sec" id="s-{{$s.ID}}"><h2>{{$s.Title}}</h2>{{end}}
{{range $p := $s.Parts}}
{{if $p.Collapsible}}<details class="part"{{if $d.HasPart $p}} open{{end}}><summary>{{$p.Title}}</summary>
{{else}}<div class="part">{{with $p.Title}}<h4>{{.}}</h4>{{end}}{{end}}
{{if $p.Body}}
<div class="body-tools js-only">
{{range $i, $y := $.Symbols}}<button type="button" class="sym{{if eq $i 0}} active{{end}}" data-sym="{{$y.Key}}" title="{{$y.Name}}">{{$y.Char}}</button>{{end}}
<input type="color" id="stamp-color" value="#d32f2f" aria-label="Farbe">
<select id="stamp-size" aria-label="Größe"><option value="1">klein</option><option value="2" selected>mittel</option><option value="3">groß</option></select>
<button type="button" id="stamp-undo" class="secondary outline">↶ Rückgängig</button>
</div>
<div class="bodies">
{{range $vi, $v := $.Views}}
<figure class="body">
<div class="body-canvas">
<img src="{{$v.Image}}" alt="Körperschema {{$v.Name}}">
<svg viewBox="0 0 {{$v.W}} {{$v.H}}" data-view="{{$vi}}" data-w="{{$v.W}}" data-h="{{$v.H}}" role="img" aria-label="Markierungen {{$v.Name}}">{{range $d.StampsFor $vi}}<text x="{{fmtNum .X}}" y="{{fmtNum .Y}}" font-size="{{fmtNum .Size}}" fill="{{.Color}}" text-anchor="middle" dominant-baseline="central">{{.Char}}</text>{{end}}</svg>
</div>
<figcaption>{{$v.Name}}</figcaption>
</figure>
{{end}}
</div>
<input type="hidden" name="stamps" id="stamps" value="{{$d.StampsJSON}}">
<p class="legend">{{range $.Symbols}}<span>{{.Char}} {{.Name}}</span>{{end}}</p>
{{end}}
{{if $p.Fields}}
<div class="fgrid">
{{range $f := $p.Fields}}
<div class="fld{{if $f.Wide}} wide{{end}}{{if eq $f.Type "check"}} chk{{end}}{{if eq $f.Type "nrs"}} small{{end}}">
{{if eq $f.Type "check"}}
<label><input type="checkbox" name="f.{{$f.Key}}" value="1"{{if $d.Get $f.Key}} checked{{end}}> {{$f.Label}}</label>
{{else}}
{{with $f.Label}}<label for="f-{{$f.Key}}">{{.}}</label>{{end}}
{{if eq $f.Type "area"}}<textarea id="f-{{$f.Key}}" name="f.{{$f.Key}}" rows="{{if $f.Rows}}{{$f.Rows}}{{else}}2{{end}}" placeholder="{{$f.Placeholder}}" aria-label="{{if $f.Label}}{{$f.Label}}{{else}}{{$s.Title}}{{end}}">{{$d.Get $f.Key}}</textarea>
{{else if eq $f.Type "select"}}<select id="f-{{$f.Key}}" name="f.{{$f.Key}}">{{range $f.Options}}<option{{if eq . ($d.Get $f.Key)}} selected{{end}}>{{.}}</option>{{end}}</select>
{{else if eq $f.Type "nrs"}}<input id="f-{{$f.Key}}" name="f.{{$f.Key}}" type="number" min="0" max="10" step="1" inputmode="numeric" value="{{$d.Get $f.Key}}">
{{else}}<input id="f-{{$f.Key}}" name="f.{{$f.Key}}" value="{{$d.Get $f.Key}}" placeholder="{{$f.Placeholder}}">{{end}}
{{end}}
</div>
{{end}}
</div>
{{end}}
{{with $p.Table}}{{$t := .}}{{$rows := $d.TableRows $t 2}}
<div class="tablewrap">
<table class="etable" data-table="{{$t.Key}}">
<thead><tr>{{range $t.Cols}}<th>{{.Label}}</th>{{end}}</tr></thead>
<tbody>
{{range $i, $row := $rows}}<tr>{{range $c := $t.Cols}}<td>
{{if eq $c.Type "select"}}<select name="{{cell $t.Key $c.Key $i}}" aria-label="{{$c.Label}}">{{range $c.Options}}<option{{if eq . (index $row $c.Key)}} selected{{end}}>{{.}}</option>{{end}}</select>
{{else if eq $c.Type "number"}}<input name="{{cell $t.Key $c.Key $i}}" value="{{index $row $c.Key}}" type="number" step="any" inputmode="decimal" aria-label="{{$c.Label}}">
{{else if eq $c.Type "date"}}<input name="{{cell $t.Key $c.Key $i}}" value="{{index $row $c.Key}}" type="date" aria-label="{{$c.Label}}">
{{else}}<input name="{{cell $t.Key $c.Key $i}}" value="{{index $row $c.Key}}" placeholder="{{$c.Placeholder}}" aria-label="{{$c.Label}}"{{if $c.List}} list="{{$c.List}}"{{end}}>{{end}}
</td>{{end}}</tr>{{end}}
</tbody>
</table>
</div>
<input type="hidden" name="t.{{$t.Key}}.n" value="{{len $rows}}">
<button type="button" class="addrow js-only secondary outline small" data-table="{{$t.Key}}">+ Zeile</button>
<small class="muted nojs-only">Für weitere Zeilen speichern.</small>
{{end}}
{{if $p.Collapsible}}</details>{{else}}</div>{{end}}
{{end}}
{{if $s.Specialty}}</details>{{else}}</section>{{end}}
{{end}}
<div class="savebar">
<button type="submit">Speichern</button>
<button type="submit" name="next" value="druck" class="secondary">Speichern &amp; Druck<span class="hide-sm">ansicht</span></button>
<a href="/patient/{{.P.ID}}" class="hide-sm">Zurück zum Patienten</a>
<small class="muted js-only hide-sm">Strg+S speichert</small>
</div>
</form>
<div class="actions spaced">
<a role="button" class="secondary outline" href="/befund/{{.B.ID}}/export">JSON exportieren</a>
<details class="danger">
<summary>Befund löschen …</summary>
<form method="post" action="/befund/{{.B.ID}}/loeschen"><button type="submit" class="danger">Endgültig löschen</button></form>
</details>
</div>
{{end}}
▾Aweb/templates/befund_print.html
@@ -0,0 +1,70 @@
{{define "befund_print"}}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.B.Title}} – {{.P.Name}} – {{de .B.Date}}</title>
<link rel="stylesheet" href="/static/befund-print.css">
</head>
<body>
{{$d := .D}}
<p class="hint">Drucken oder als PDF speichern: <kbd>Strg</kbd>+<kbd>P</kbd>. Es erscheinen nur ausgefüllte Felder.
{{if .Sessions}}<a href="/befund/{{.B.ID}}/druck">ohne Behandlungsverlauf</a>{{else}}<a href="/befund/{{.B.ID}}/druck?verlauf=1">mit Behandlungsverlauf</a>{{end}} ·
<a href="/befund/{{.B.ID}}">zurück zum Befund</a></p>
<header>
<img src="/static/logo.webp" alt="" class="logo">
<div class="htext">
<h1>Physiotherapeutischer Befund</h1>
<p><strong>{{.P.Name}}</strong>{{with .P.Dob}} · geb. {{.}}{{end}}</p>
</div>
<div class="meta">{{.B.Title}}<br>{{de .B.Date}}<br>{{.Practice}}</div>
</header>
{{range $s := .Sections}}{{if $d.HasSection $s}}
<section>
<h2>{{$s.Title}}</h2>
{{range $p := $s.Parts}}{{if $d.HasPart $p}}
{{with $p.Title}}<h3>{{.}}</h3>{{end}}
{{if and $p.Body $d.Stamps}}
<div class="bodies">
{{range $vi, $v := $.Views}}
<figure><div class="body-canvas"><img src="{{$v.Image}}" alt="{{$v.Name}}"><svg viewBox="0 0 {{$v.W}} {{$v.H}}">{{range $d.StampsFor $vi}}<text x="{{fmtNum .X}}" y="{{fmtNum .Y}}" font-size="{{fmtNum .Size}}" fill="{{.Color}}" text-anchor="middle" dominant-baseline="central">{{.Char}}</text>{{end}}</svg></div><figcaption>{{$v.Name}}</figcaption></figure>
{{end}}
<ul class="legend">{{range $d.UsedSymbols}}<li><b>{{.Char}}</b> {{.Name}}</li>{{end}}</ul>
</div>
{{end}}
<dl>
{{range $f := $p.Fields}}{{with $d.Get $f.Key}}
{{if eq $f.Type "check"}}<dt class="chk">☑ {{$f.Label}}</dt>
{{else}}{{if $f.Label}}<dt>{{$f.Label}}</dt>{{end}}<dd>{{.}}</dd>{{end}}
{{end}}{{end}}
</dl>
{{with $p.Table}}{{$t := .}}{{with index $d.Tables $t.Key}}
<table>
<thead><tr>{{range $t.Cols}}<th>{{.Label}}</th>{{end}}</tr></thead>
<tbody>{{range $row := .}}<tr>{{range $c := $t.Cols}}<td>{{if eq $c.Type "date"}}{{de (index $row $c.Key)}}{{else}}{{index $row $c.Key}}{{end}}</td>{{end}}</tr>{{end}}</tbody>
</table>
{{end}}{{end}}
{{end}}{{end}}
</section>
{{end}}{{end}}
{{with .Sessions}}
<section>
<h2>Behandlungsverlauf</h2>
<table>
<thead><tr><th>Datum</th><th>Ziel / Maßnahmen</th><th>Bemerkung / Re-Test</th></tr></thead>
<tbody>{{range .}}<tr><td class="nowrap">{{de .Date}}</td><td>{{.Goal}}</td><td>{{.Note}}</td></tr>{{end}}</tbody>
</table>
</section>
{{end}}
<footer>
<div class="sig">Datum, Unterschrift Therapeut*in</div>
<div class="sig">Datum, Unterschrift Patient*in</div>
</footer>
</body>
</html>{{end}}
▾Aweb/templates/compare.html
@@ -0,0 +1,40 @@
{{define "title"}}Vergleich – {{.P.Name}}{{end}}
{{define "content"}}
<nav aria-label="breadcrumb"><ul><li><a href="/patienten">Patienten</a></li><li><a href="/patient/{{.P.ID}}">{{.P.Name}}</a></li><li>Vergleich</li></ul></nav>
<h2>Befundvergleich</h2>
{{if lt (len .Befunde) 2}}<p class="muted">Für einen Vergleich werden mindestens zwei Befunde benötigt.</p>{{else}}
<form method="get" action="/patient/{{.P.ID}}/vergleich" class="row compare-pick">
<select name="a" aria-label="Ausgangsbefund">{{range .Befunde}}<option value="{{.ID}}"{{if and $.A (eq .ID $.A.ID)}} selected{{end}}>{{de .Date}} – {{.Title}}</option>{{end}}</select>
<span class="arrow" aria-hidden="true">→</span>
<select name="b" aria-label="Vergleichsbefund">{{range .Befunde}}<option value="{{.ID}}"{{if and $.B (eq .ID $.B.ID)}} selected{{end}}>{{de .Date}} – {{.Title}}</option>{{end}}</select>
<button type="submit">Vergleichen</button>
</form>
{{if .A}}
{{range .Tables}}
<article>
<h3>{{.Title}}</h3>
<div class="tablewrap"><table class="list">
<thead><tr><th></th><th>Wert</th><th>{{de $.A.Date}}</th><th>{{de $.B.Date}}</th><th>Δ</th></tr></thead>
<tbody>
{{range .Rows}}{{$k := .Key}}{{range $i, $c := .Cells}}
<tr><td>{{if eq $i 0}}<strong>{{$k}}</strong>{{end}}</td><td class="muted">{{$c.Label}}</td><td>{{$c.A}}</td><td>{{$c.B}}</td><td class="delta">{{$c.Delta}}</td></tr>
{{end}}{{end}}
</tbody>
</table></div>
</article>
{{end}}
<article>
<h3>Geänderte Angaben</h3>
{{if .Fields}}
<div class="tablewrap"><table class="list cmp">
<thead><tr><th>Feld</th><th>{{de .A.Date}} – {{.A.Title}}</th><th>{{de .B.Date}} – {{.B.Title}}</th></tr></thead>
<tbody>{{range .Fields}}<tr><td><small class="muted">{{.Section}}</small><br>{{.Label}}</td><td class="pre">{{.A}}</td><td class="pre">{{.B}}</td></tr>{{end}}</tbody>
</table></div>
{{else}}<p class="muted">Keine Unterschiede in den Textfeldern.</p>{{end}}
</article>
<p class="muted"><small>Δ = Differenz der Zahlenwerte (bei Neutral-Null je Position, nur geänderte). Ob eine Änderung klinisch relevant ist, hängt vom Instrument ab (MCID) – bei NRS und PSFS meist ca. 2 Punkte.</small></p>
{{end}}
{{end}}
{{end}}
▾Aweb/templates/edit.html
@@ -0,0 +1,42 @@
{{define "title"}}{{if .New}}Neu: {{kindLabel .E.Kind}}{{else}}{{.E.Title}} bearbeiten{{end}}{{end}}
{{define "content"}}
{{$e := .E}}
<article>
<h2>{{if .New}}Neu: {{kindLabel $e.Kind}}{{else}}Bearbeiten{{end}}</h2>
{{with .Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<form method="post" action="{{if .New}}/eintrag/neu{{else}}/eintrag/{{$e.ID}}/bearbeiten{{end}}">
{{if .LinkFrom}}<input type="hidden" name="link_from" value="{{.LinkFrom}}">{{end}}
<div class="grid">
<label>Art
<select name="kind">
{{range .Kinds}}<option value="{{.Key}}"{{if eq .Key $e.Kind}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</label>
<label>Körperregion
<input name="region" value="{{$e.Region}}" list="regions" placeholder="z. B. Knie, LWS, Schulter">
<datalist id="regions">{{range .Regions}}<option value="{{.}}">{{end}}</datalist>
</label>
</div>
<label>Titel <input name="title" value="{{$e.Title}}" required{{if .New}} autofocus{{end}}></label>
<label>Kurzbeschreibung <textarea name="summary" rows="2" placeholder="Ein, zwei Sätze – erscheint in der Übersicht">{{$e.Summary}}</textarea></label>
<label>Schlagworte <input name="tags" value="{{$e.Tags}}" placeholder="durch Komma getrennt: Sehne, Sport, Überlastung"></label>
<label>Inhalt
<textarea name="body" rows="16" class="mono" placeholder="## Befund&#10;- …&#10;&#10;## Behandlung&#10;1. …">{{$e.Body}}</textarea>
<small>Markdown: <code>## Überschrift</code>, <code>- Liste</code>, <code>**fett**</code>, Tabellen mit <code>| a | b |</code></small>
</label>
<fieldset class="exercise">
<legend>Nur für Übungen</legend>
<label>Standard-Dosierung <input name="dosage" value="{{$e.Dosage}}" placeholder="z. B. 3 × 12 Wdh., 2× täglich"></label>
<label>Anleitung für Patienten (Handout)
<textarea name="patient_text" rows="6" placeholder="Einfache Sprache, Sie-Form. Wird im Handout gedruckt.">{{$e.PatientText}}</textarea>
</label>
</fieldset>
<div class="actions">
<button type="submit">Speichern</button>
<a role="button" class="secondary outline" href="{{if .New}}/{{else}}/eintrag/{{$e.ID}}{{end}}">Abbrechen</a>
</div>
</form>
</article>
{{end}}
▾Aweb/templates/entry.html
@@ -0,0 +1,140 @@
{{define "title"}}{{.E.Title}}{{end}}
{{define "content"}}
{{$e := .E}}
<article>
<header>
<span class="kind kind-{{$e.Kind}}">{{kindLabel $e.Kind}}</span>{{with $e.Region}} <a class="region" href="/?region={{.}}">{{.}}</a>{{end}}
<h1>{{$e.Title}}</h1>
{{with $e.Summary}}<p class="lead">{{.}}</p>{{end}}
{{with tags $e.Tags}}<p class="tags">{{range .}}<a href="/?q={{.}}">#{{.}}</a> {{end}}</p>{{end}}
<div class="actions">
<a role="button" class="outline" href="/eintrag/{{$e.ID}}/bearbeiten">Bearbeiten</a>
</div>
</header>
{{if eq $e.Kind "uebung"}}
{{with $e.Dosage}}<p class="dosage"><strong>Dosierung:</strong> {{.}}</p>{{end}}
{{end}}
{{with $e.Body}}<section class="prose">{{md .}}</section>{{end}}
{{if eq $e.Kind "uebung"}}{{with $e.PatientText}}
<section class="patient">
<h3>Anleitung für Patienten <small class="muted">(erscheint im Handout)</small></h3>
<div class="prose">{{md .}}</div>
</section>
{{end}}{{end}}
<footer class="muted">Angelegt {{date $e.Created}} · geändert {{date $e.Updated}}</footer>
</article>
{{if eq $e.Kind "uebung"}}
<article id="handout">
<h3>Zu Handout hinzufügen</h3>
{{if .Added}}<p class="ok">Hinzugefügt. <a href="/handout/{{.Added}}">Zum Handout →</a></p>{{end}}
<div class="grid">
{{if .Handouts}}
<form method="post" action="/handouts/uebung" class="row">
<select name="handout" aria-label="Handout" required>
{{range .Handouts}}<option value="{{.ID}}">{{.Title}}{{with .Recipient}} – {{.}}{{end}} ({{.Count}})</option>{{end}}
</select>
<input type="hidden" name="entry" value="{{$e.ID}}">
<input type="hidden" name="from" value="entry">
<button type="submit">Hinzufügen</button>
</form>
{{end}}
<form method="post" action="/handouts" class="row">
<input name="title" placeholder="Neues Handout, z. B. Knie Woche 1" aria-label="Titel">
<input type="hidden" name="entry" value="{{$e.ID}}">
<button type="submit" class="secondary">Neues Handout</button>
</form>
</div>
</article>
{{end}}
<article id="verknuepft">
<h3>Verknüpft</h3>
{{range .Groups}}
<h4>{{.Label}}</h4>
<ul class="links">
{{range .Entries}}
<li>
<a href="/eintrag/{{.ID}}">{{.Title}}</a>{{with .Summary}} <small class="muted">– {{.}}</small>{{end}}
<form method="post" action="/eintrag/{{$e.ID}}/entknuepfen/{{.ID}}" class="inline"><button type="submit" class="link" title="Verknüpfung lösen">✕</button></form>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">Noch nichts verknüpft.</p>
{{end}}
{{if .Linkable}}
<form method="post" action="/eintrag/{{$e.ID}}/verknuepfen" class="row">
<select name="other" aria-label="Eintrag verknüpfen" required>
<option value="">Vorhandenen Eintrag verknüpfen …</option>
{{range .Linkable}}<option value="{{.ID}}">{{kindLabel .Kind}}: {{.Title}}</option>{{end}}
</select>
<button type="submit">Verknüpfen</button>
</form>
{{end}}
<p class="new-linked">Neu anlegen und verknüpfen:
{{range .Kinds}}<a href="/eintrag/neu?art={{.Key}}&amp;von={{$e.ID}}&amp;region={{$e.Region}}">{{.Label}}</a> {{end}}
</p>
</article>
<article id="medien">
<h3>Bilder, Videos, PDFs</h3>
{{if .Media}}
<div class="media">
{{range .Media}}
<figure>
{{if isImage .Filename}}<a href="/medien/{{.Filename}}"><img src="/medien/{{.Filename}}" alt="{{.Caption}}" loading="lazy"></a>
{{else if isVideo .Filename}}<video src="/medien/{{.Filename}}" controls preload="metadata"></video>
{{else}}<a href="/medien/{{.Filename}}" class="file">📄 PDF öffnen</a>{{end}}
<figcaption>{{.Caption}}
<form method="post" action="/medium/{{.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Löschen">✕</button></form>
</figcaption>
</figure>
{{end}}
</div>
{{end}}
<form method="post" action="/eintrag/{{$e.ID}}/medien" enctype="multipart/form-data" class="row">
<input type="file" name="files" accept="image/*,video/*,application/pdf" multiple required aria-label="Dateien">
<input name="caption" placeholder="Beschriftung (optional)" aria-label="Beschriftung">
<button type="submit">Hochladen</button>
</form>
<small class="muted">Auf Tablet/Handy kann direkt die Kamera benutzt werden. Erstes Bild erscheint im Handout.</small>
</article>
<article id="quellen">
<h3>Quellen</h3>
{{if .Sources}}
<ul>
{{range .Sources}}
<li>
{{if .URL}}<a href="{{.URL}}" rel="noopener noreferrer" target="_blank">{{.Ref}}</a>{{else}}{{.Ref}}{{end}}{{with .Page}}, {{.}}{{end}}{{with .Note}} <small class="muted">– {{.}}</small>{{end}}
<form method="post" action="/quelle/{{.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Löschen">✕</button></form>
</li>
{{end}}
</ul>
{{end}}
<form method="post" action="/eintrag/{{$e.ID}}/quelle">
<div class="grid">
<input name="ref" placeholder="Buch / Artikel / Studie" aria-label="Quelle" required>
<input name="page" placeholder="Seite / Kapitel" aria-label="Seite">
</div>
<div class="grid">
<input name="url" placeholder="Link (optional, z. B. Calibre oder DOI)" aria-label="Link">
<input name="note" placeholder="Notiz (optional)" aria-label="Notiz">
</div>
<button type="submit" class="secondary">Quelle hinzufügen</button>
</form>
</article>
<details class="danger">
<summary>Eintrag löschen …</summary>
<form method="post" action="/eintrag/{{$e.ID}}/loeschen">
<p>„{{$e.Title}}“ mit allen Medien, Quellen und Verknüpfungen endgültig löschen?</p>
<button type="submit" class="danger">Endgültig löschen</button>
</form>
</details>
{{end}}
▾Aweb/templates/handout.html
@@ -0,0 +1,60 @@
{{define "title"}}{{.H.Title}}{{end}}
{{define "content"}}
{{$h := .H}}
{{if .PatientID}}<nav aria-label="breadcrumb"><ul><li><a href="/patienten">Patienten</a></li><li><a href="/patient/{{.PatientID}}">{{.PatientName}}</a></li><li>{{$h.Title}}</li></ul></nav>{{end}}
<article>
<form method="post" action="/handout/{{$h.ID}}">
<div class="grid">
<label>Titel <input name="title" value="{{$h.Title}}" required></label>
<label>Für <input name="recipient" value="{{$h.Recipient}}" placeholder="Name (optional)"></label>
</div>
<label>Hinweise oben im Handout
<textarea name="notes" rows="3" placeholder="z. B. Übungen 1× täglich, Schmerz bis 3/10 ist in Ordnung">{{$h.Notes}}</textarea>
</label>
<div class="actions">
<button type="submit" class="secondary">Speichern</button>
<a role="button" href="/handout/{{$h.ID}}/druck" target="_blank">Druckansicht / PDF</a>
</div>
</form>
</article>
<h3>Übungen</h3>
{{range $i, $it := .Items}}
<article class="item" id="u{{$it.Entry.ID}}">
<div class="item-head">
<strong>{{add1 $i}}. <a href="/eintrag/{{$it.Entry.ID}}">{{$it.Entry.Title}}</a></strong>
<span class="item-actions">
{{if $i}}<form method="post" action="/handout/{{$h.ID}}/uebung/{{$it.Entry.ID}}/hoch" class="inline"><button type="submit" class="link" title="Nach oben">▲</button></form>{{end}}
<form method="post" action="/handout/{{$h.ID}}/uebung/{{$it.Entry.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Entfernen">✕</button></form>
</span>
</div>
<form method="post" action="/handout/{{$h.ID}}/uebung/{{$it.Entry.ID}}" class="row">
<input name="dosage" value="{{$it.Dosage}}" placeholder="Dosierung" aria-label="Dosierung">
<input name="note" value="{{$it.Note}}" placeholder="Persönlicher Hinweis (optional)" aria-label="Hinweis">
<button type="submit" class="secondary outline">Übernehmen</button>
</form>
</article>
{{else}}
<p class="muted">Noch keine Übungen.</p>
{{end}}
{{if .Exercises}}
<form method="post" action="/handout/{{$h.ID}}/uebung" class="row">
<select name="entry" aria-label="Übung" required>
<option value="">Übung hinzufügen …</option>
{{range .Exercises}}<option value="{{.ID}}">{{with .Region}}{{.}}: {{end}}{{.Title}}</option>{{end}}
</select>
<button type="submit">Hinzufügen</button>
</form>
{{else}}
<p class="muted">Alle Übungen sind bereits drin – <a href="/eintrag/neu?art=uebung">neue Übung anlegen</a>.</p>
{{end}}
<div class="actions spaced">
<form method="post" action="/handout/{{$h.ID}}/kopieren" class="inline"><button type="submit" class="secondary outline">Als Vorlage kopieren</button></form>
<details class="danger">
<summary>Handout löschen …</summary>
<form method="post" action="/handout/{{$h.ID}}/loeschen"><button type="submit" class="danger">Endgültig löschen</button></form>
</details>
</div>
{{end}}
▾Aweb/templates/handouts.html
@@ -0,0 +1,21 @@
{{define "title"}}Handouts{{end}}
{{define "content"}}
<h2>Übungs-Handouts</h2>
<form method="post" action="/handouts" class="row">
<input name="title" placeholder="Titel, z. B. Schulter – Woche 1" aria-label="Titel" required>
<input name="recipient" placeholder="Für (optional)" aria-label="Für">
<button type="submit">Neues Handout</button>
</form>
{{if .Handouts}}
<div class="cards">
{{range .Handouts}}
<a class="card" href="/handout/{{.ID}}">
<strong>{{.Title}}</strong>
<span class="summary">{{with .Recipient}}Für {{.}} · {{end}}{{.Count}} Übung(en) · {{date .Updated}}</span>
</a>
{{end}}
</div>
{{else}}
<p class="muted">Noch keine Handouts.</p>
{{end}}
{{end}}
▾Aweb/templates/index.html
@@ -0,0 +1,34 @@
{{define "title"}}Wissen{{end}}
{{define "content"}}
<form method="get" action="/" role="search" class="search">
<input type="search" name="q" value="{{.Q}}" placeholder="Suchen: Knie, Sehne, Lachman …" aria-label="Suche" autofocus>
{{if .Art}}<input type="hidden" name="art" value="{{.Art}}">{{end}}
{{if .Region}}<input type="hidden" name="region" value="{{.Region}}">{{end}}
<button type="submit">Suchen</button>
</form>
<nav class="chips" aria-label="Art">
<a href="/?q={{.Q}}&amp;region={{.Region}}" {{if not .Art}}aria-current="true"{{end}}>Alle <small>{{.Total}}</small></a>
{{range .Kinds}}<a href="/?q={{$.Q}}&amp;art={{.Key}}&amp;region={{$.Region}}" {{if eq $.Art .Key}}aria-current="true"{{end}}>{{.Plural}} <small>{{index $.Counts .Key}}</small></a>{{end}}
</nav>
{{if .Regions}}
<nav class="chips regions" aria-label="Körperregion">
<a href="/?q={{.Q}}&amp;art={{.Art}}" {{if not .Region}}aria-current="true"{{end}}>Alle Regionen</a>
{{range .Regions}}<a href="/?q={{$.Q}}&amp;art={{$.Art}}&amp;region={{.}}" {{if eq $.Region .}}aria-current="true"{{end}}>{{.}}</a>{{end}}
</nav>
{{end}}
{{if .Entries}}
<div class="cards">
{{range .Entries}}
<a class="card" href="/eintrag/{{.ID}}">
<span class="kind kind-{{.Kind}}">{{kindLabel .Kind}}</span>{{with .Region}} <span class="region">{{.}}</span>{{end}}
<strong>{{.Title}}</strong>
{{with .Summary}}<span class="summary">{{.}}</span>{{end}}
</a>
{{end}}
</div>
{{else}}
<p class="muted">Keine Einträge gefunden.{{if .Q}} <a href="/eintrag/neu?art={{if .Art}}{{.Art}}{{else}}krankheitsbild{{end}}">Neuen Eintrag anlegen?</a>{{end}}</p>
{{end}}
{{end}}
▾Aweb/templates/layout.html
@@ -0,0 +1,42 @@
{{define "layout"}}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{template "title" .}} · {{.Practice}}</title>
<link rel="stylesheet" href="/static/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/befund.js" defer></script>
</head>
<body>
<header class="container">
<nav>
<ul><li><a href="/" class="brand"><strong>{{.Practice}}</strong></a></li></ul>
{{if ne .Path "/login"}}
<ul>
<li><a href="/"{{if eq .Path "/"}} aria-current="page"{{end}}>Wissen</a></li>
<li><a href="/patienten"{{if eq .Path "/patienten"}} aria-current="page"{{end}}>Patienten</a></li>
<li><a href="/handouts"{{if eq .Path "/handouts"}} aria-current="page"{{end}}>Handouts</a></li>
<li>
<details class="dropdown">
<summary>+ Neu</summary>
<ul dir="rtl">
{{range .Kinds}}<li><a href="/eintrag/neu?art={{.Key}}">{{.Label}}</a></li>{{end}}
</ul>
</details>
</li>
</ul>
{{end}}
</nav>
</header>
<main class="container">
{{template "content" .}}
</main>
{{if ne .Path "/login"}}
<footer class="container site-footer">
<form method="post" action="/logout" class="inline"><button type="submit" class="link">Abmelden</button></form>
</footer>
{{end}}
</body>
</html>{{end}}
▾Aweb/templates/login.html
@@ -0,0 +1,12 @@
{{define "title"}}Anmelden{{end}}
{{define "content"}}
<article class="narrow">
<h2>Anmelden</h2>
{{with .Error}}<p class="error" role="alert">{{.}}</p>{{end}}
<form method="post" action="/login">
<label>Benutzername <input name="username" autocomplete="username" required autofocus></label>
<label>Passwort <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Anmelden</button>
</form>
</article>
{{end}}
▾Aweb/templates/patient.html
@@ -0,0 +1,120 @@
{{define "title"}}{{.P.Name}}{{end}}
{{define "content"}}
{{$p := .P}}
<nav aria-label="breadcrumb"><ul><li><a href="/patienten">Patienten</a></li><li>{{$p.Name}}</li></ul></nav>
<header class="patient-head">
<h1>{{$p.Name}}{{if $p.Archived}} <small class="muted">(archiviert)</small>{{end}}</h1>
<p class="muted">{{with $p.Dob}}geb. {{.}}{{end}}{{with $p.Phone}} · {{.}}{{end}}</p>
</header>
<article id="befunde">
<h3>Befunde</h3>
{{if .Befunde}}
<div class="tablewrap">
<table class="list">
<thead><tr><th>Datum</th><th>Befund</th><th class="hide-sm">Diagnose</th><th></th></tr></thead>
<tbody>
{{range .Befunde}}
<tr><td>{{de .Date}}</td><td><a href="/befund/{{.ID}}"><strong>{{.Title}}</strong></a></td><td class="muted hide-sm">{{diag .Data}}</td>
<td class="nowrap"><a href="/befund/{{.ID}}/druck" target="_blank">Druck</a></td></tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="muted">Noch kein Befund.</p>{{end}}
<div class="actions">
<form method="post" action="/patient/{{$p.ID}}/befund" class="inline"><button type="submit">+ Erstbefund (leer)</button></form>
{{with .Befunde}}{{$last := index . 0}}
<form method="post" action="/patient/{{$p.ID}}/befund" class="inline">
<input type="hidden" name="kopie" value="{{$last.ID}}">
<button type="submit" class="secondary" title="Übernimmt alle Angaben aus dem letzten Befund – nur Veränderungen neu erfassen">+ Wiederbefund (Kopie vom {{de $last.Date}})</button>
</form>
{{if gt (len .) 1}}<a role="button" class="outline" href="/patient/{{$p.ID}}/vergleich">Befunde vergleichen</a>{{end}}
{{end}}
</div>
</article>
{{if gt (len .Trend) 1}}
<article id="trend">
<h3>Verlauf der Kernwerte</h3>
<div class="tablewrap">
<table class="list">
<thead><tr><th>Datum</th><th class="hide-sm">Befund</th><th>NRS jetzt</th><th>NRS max</th><th>PSFS Ø</th></tr></thead>
<tbody>{{range .Trend}}<tr><td>{{de .Date}}</td><td class="hide-sm"><a href="/befund/{{.ID}}">{{.Title}}</a></td><td>{{.Now}}</td><td>{{.Worst}}</td><td>{{.PSFS}}</td></tr>{{end}}</tbody>
</table>
</div>
<small class="muted">Orientierung: Änderungen um etwa 2 Punkte auf NRS bzw. PSFS gelten in Studien häufig als klinisch relevant. Details im Vergleich.</small>
</article>
{{end}}
<article id="verlauf">
<h3>Behandlungsverlauf</h3>
<form method="post" action="/patient/{{$p.ID}}/verlauf" class="session-form">
<input type="date" name="date" value="{{.Today}}" aria-label="Datum">
<input name="goal" placeholder="Ziel + Maßnahme(n)" aria-label="Ziel und Maßnahmen">
<textarea name="note" rows="2" placeholder="Reaktion, Re-Test, Bemerkung" aria-label="Bemerkung"></textarea>
<button type="submit">+ Eintrag</button>
</form>
{{range .Sessions}}
<div class="session">
<div class="session-head"><strong>{{de .Date}}</strong> {{.Goal}}
<form method="post" action="/verlauf/{{.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Löschen">✕</button></form>
</div>
{{with .Note}}<div class="session-note">{{.}}</div>{{end}}
</div>
{{else}}<p class="muted">Noch keine Behandlungen dokumentiert.</p>{{end}}
</article>
<div class="grid">
<article id="wissen">
<h3>Wissen zum Fall</h3>
{{if .Linked}}<ul class="links">{{range .Linked}}
<li><span class="kind kind-{{.Kind}}">{{kindLabel .Kind}}</span> <a href="/eintrag/{{.ID}}">{{.Title}}</a>
<form method="post" action="/patient/{{$p.ID}}/wissen/{{.ID}}/loeschen" class="inline"><button type="submit" class="link" title="Entfernen">✕</button></form></li>
{{end}}</ul>{{else}}<p class="muted">Krankheitsbild oder Protokoll verknüpfen – dann sind Tests, Übungen und Quellen einen Klick entfernt.</p>{{end}}
{{if .Linkable}}
<form method="post" action="/patient/{{$p.ID}}/wissen" class="row">
<select name="entry" aria-label="Eintrag" required><option value="">Eintrag wählen …</option>
{{range .Linkable}}<option value="{{.ID}}">{{kindLabel .Kind}}: {{.Title}}</option>{{end}}
</select>
<button type="submit" class="secondary">Verknüpfen</button>
</form>
{{end}}
</article>
<article id="handouts">
<h3>Heimprogramme</h3>
{{if .Handouts}}<ul>{{range .Handouts}}<li><a href="/handout/{{.ID}}">{{.Title}}</a> <small class="muted">{{.Count}} Übung(en) · {{date .Updated}}</small></li>{{end}}</ul>{{end}}
<form method="post" action="/patient/{{$p.ID}}/handout" class="row">
<input name="title" placeholder="Titel, z. B. Woche 1" aria-label="Titel">
<button type="submit" class="secondary">+ Handout</button>
</form>
</article>
</div>
<details>
<summary>Stammdaten bearbeiten</summary>
<form method="post" action="/patient/{{$p.ID}}">
<div class="grid">
<label>Name <input name="name" value="{{$p.Name}}" required></label>
<label>Geburtsdatum/Alter <input name="dob" value="{{$p.Dob}}"></label>
<label>Telefon <input name="phone" value="{{$p.Phone}}"></label>
</div>
<label>Notizen <textarea name="notes" rows="3">{{$p.Notes}}</textarea></label>
<button type="submit">Speichern</button>
</form>
<div class="actions">
<form method="post" action="/patient/{{$p.ID}}/archiv" class="inline">
<input type="hidden" name="archiv" value="{{if $p.Archived}}0{{else}}1{{end}}">
<button type="submit" class="secondary outline">{{if $p.Archived}}Aus Archiv holen{{else}}Archivieren (Behandlung abgeschlossen){{end}}</button>
</form>
<details class="danger">
<summary>Patient löschen …</summary>
<form method="post" action="/patient/{{$p.ID}}/loeschen">
<p>Alle Befunde, Behandlungen und Verknüpfungen von „{{$p.Name}}“ werden endgültig gelöscht.</p>
<button type="submit" class="danger">Endgültig löschen</button>
</form>
</details>
</div>
</details>
{{end}}
▾Aweb/templates/patients.html
@@ -0,0 +1,48 @@
{{define "title"}}Patienten{{end}}
{{define "content"}}
<h2>Patienten{{if .Archived}} – Archiv{{end}}</h2>
{{with .Imported}}<p class="ok" role="status">{{.}} Befund(e) importiert.</p>{{end}}
{{with .ImportErrors}}<div class="error" role="alert"><p>Nicht importiert:</p><ul>{{range .}}<li>{{.}}</li>{{end}}</ul></div>{{end}}
<form method="get" action="/patienten" role="search" class="search">
<input type="search" name="q" value="{{.Q}}" placeholder="Name oder Geburtsdatum" aria-label="Patient suchen">
{{if .Archived}}<input type="hidden" name="archiv" value="1">{{end}}
<button type="submit">Suchen</button>
</form>
{{if .Patients}}
<div class="tablewrap">
<table class="list">
<thead><tr><th>Name</th><th>Geburtsdatum</th><th>Befunde</th><th>Behandlungen</th><th>Zuletzt</th></tr></thead>
<tbody>
{{range .Patients}}
<tr><td><a href="/patient/{{.ID}}"><strong>{{.Name}}</strong></a></td><td>{{.Dob}}</td><td>{{.Befunde}}</td><td>{{.Sessions}}</td><td>{{de .LastDate}}</td></tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="muted">Keine Patienten gefunden.</p>{{end}}
<p>{{if .Archived}}<a href="/patienten">← Aktive Patienten</a>{{else}}<a href="/patienten?archiv=1">Archiv anzeigen</a>{{end}}</p>
<div class="grid">
<article>
<h3>Neuer Patient</h3>
<form method="post" action="/patienten">
<input name="name" placeholder="Name" aria-label="Name" required>
<div class="grid">
<input name="dob" placeholder="Geburtsdatum / Alter" aria-label="Geburtsdatum">
<input name="phone" placeholder="Telefon (optional)" aria-label="Telefon">
</div>
<button type="submit">Anlegen</button>
</form>
</article>
<article>
<h3>Alte Befunde importieren</h3>
<p class="muted">JSON-Dateien der bisherigen Befundseite (<code>Befund_*.json</code>) oder Exporte von hier. Patienten werden über den Namen zugeordnet oder neu angelegt; der Behandlungsverlauf wird übernommen.</p>
<form method="post" action="/patienten/import" enctype="multipart/form-data">
<input type="file" name="files" accept="application/json,.json" multiple required aria-label="JSON-Dateien">
<button type="submit" class="secondary">Importieren</button>
</form>
</article>
</div>
{{end}}
▾Aweb/templates/print.html
@@ -0,0 +1,35 @@
{{define "print"}}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.H.Title}}</title>
<link rel="stylesheet" href="/static/print.css">
</head>
<body>
<p class="hint">Drucken oder als PDF speichern: <kbd>Strg</kbd>+<kbd>P</kbd> (Tablet/Handy: Teilen → Drucken). Dieser Hinweis erscheint nicht im Ausdruck.</p>
<header>
<div>
<h1>{{.H.Title}}</h1>
{{with .H.Recipient}}<p class="for">für {{.}}</p>{{end}}
</div>
<div class="meta">{{.Practice}}<br>{{date .Today}}</div>
</header>
{{with .H.Notes}}<section class="notes">{{md .}}</section>{{end}}
{{range $i, $it := .Items}}
<section class="ex">
<h2><span class="num">{{add1 $i}}</span> {{$it.Entry.Title}}</h2>
<div class="ex-body">
{{with images $it.Media}}<div class="pics">{{range .}}<img src="/medien/{{.Filename}}" alt="{{.Caption}}">{{end}}</div>{{end}}
<div class="text">
{{if $it.Entry.PatientText}}{{md $it.Entry.PatientText}}{{else}}<p>{{$it.Entry.Summary}}</p>{{end}}
{{with $it.Dosage}}<p class="dose">{{.}}</p>{{end}}
{{with $it.Note}}<p class="note">{{.}}</p>{{end}}
</div>
</div>
</section>
{{end}}
<footer>Bei starken oder zunehmenden Beschwerden die Übung pausieren und Rücksprache halten.</footer>
</body>
</html>{{end}}