data.go
| 1 | package befund |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "math" |
| 7 | "net/url" |
| 8 | "regexp" |
| 9 | "sort" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | type Row map[string]string |
| 15 | |
| 16 | // Stamp ist ein Symbol im Körperschema. X/Y sind relativ (0..1), damit die |
| 17 | // Position unabhängig von Bildschirm- und Druckgröße stimmt. |
| 18 | type Stamp struct { |
| 19 | V int `json:"v"` // Ansicht (Index in Views) |
| 20 | X float64 `json:"x"` |
| 21 | Y float64 `json:"y"` |
| 22 | S string `json:"s"` // Symbol-Key |
| 23 | C string `json:"c"` // Farbe #rrggbb |
| 24 | Z int `json:"z"` // Größe 1..3 |
| 25 | } |
| 26 | |
| 27 | type Data struct { |
| 28 | Fields map[string]string `json:"fields"` |
| 29 | Tables map[string][]Row `json:"tables"` |
| 30 | Stamps []Stamp `json:"stamps"` |
| 31 | } |
| 32 | |
| 33 | func New() Data { return Data{Fields: map[string]string{}, Tables: map[string][]Row{}} } |
| 34 | |
| 35 | func Parse(raw string) (Data, error) { |
| 36 | d := New() |
| 37 | if strings.TrimSpace(raw) == "" { |
| 38 | return d, nil |
| 39 | } |
| 40 | if err := json.Unmarshal([]byte(raw), &d); err != nil { |
| 41 | return New(), fmt.Errorf("befund-daten: %w", err) |
| 42 | } |
| 43 | if d.Fields == nil { |
| 44 | d.Fields = map[string]string{} |
| 45 | } |
| 46 | if d.Tables == nil { |
| 47 | d.Tables = map[string][]Row{} |
| 48 | } |
| 49 | return d, nil |
| 50 | } |
| 51 | |
| 52 | func (d Data) JSON() string { |
| 53 | b, _ := json.Marshal(d) |
| 54 | return string(b) |
| 55 | } |
| 56 | |
| 57 | func (d Data) Get(k string) string { return d.Fields[k] } |
| 58 | |
| 59 | // HasSection: enthält die Sektion irgendeinen Wert? (Fachbereiche aufklappen, Druck filtern) |
| 60 | func (d Data) HasSection(s Section) bool { |
| 61 | for _, p := range s.Parts { |
| 62 | if d.HasPart(p) { |
| 63 | return true |
| 64 | } |
| 65 | } |
| 66 | return false |
| 67 | } |
| 68 | |
| 69 | func (d Data) HasPart(p Part) bool { |
| 70 | for _, f := range p.Fields { |
| 71 | if strings.TrimSpace(d.Fields[f.Key]) != "" { |
| 72 | return true |
| 73 | } |
| 74 | } |
| 75 | if p.Table != nil && len(d.Tables[p.Table.Key]) > 0 { |
| 76 | return true |
| 77 | } |
| 78 | return p.Body && len(d.Stamps) > 0 |
| 79 | } |
| 80 | |
| 81 | // ---------- Formular → Daten ---------- |
| 82 | |
| 83 | var colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) |
| 84 | |
| 85 | // FromForm liest das Befundformular. Tabellenzeilen heißen t.<tabelle>.<i>.<spalte>, |
| 86 | // komplett leere Zeilen werden verworfen. |
| 87 | func FromForm(form url.Values) Data { |
| 88 | d := New() |
| 89 | for k, f := range fieldIndex { |
| 90 | v := strings.TrimSpace(strings.ReplaceAll(form.Get("f."+k), "\r\n", "\n")) |
| 91 | if f.Type == "check" { |
| 92 | if v != "" { |
| 93 | d.Fields[k] = "1" |
| 94 | } |
| 95 | continue |
| 96 | } |
| 97 | if v != "" { |
| 98 | d.Fields[k] = v |
| 99 | } |
| 100 | } |
| 101 | for _, t := range Tables { |
| 102 | n, _ := strconv.Atoi(form.Get("t." + t.Key + ".n")) |
| 103 | n = min(n, 200) |
| 104 | for i := range n { |
| 105 | row := Row{} |
| 106 | for _, c := range t.Cols { |
| 107 | if v := strings.TrimSpace(form.Get(fmt.Sprintf("t.%s.%d.%s", t.Key, i, c.Key))); v != "" { |
| 108 | row[c.Key] = v |
| 109 | } |
| 110 | } |
| 111 | if len(row) > 0 { |
| 112 | d.Tables[t.Key] = append(d.Tables[t.Key], row) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | var stamps []Stamp |
| 117 | if json.Unmarshal([]byte(form.Get("stamps")), &stamps) == nil { |
| 118 | d.Stamps = CleanStamps(stamps) |
| 119 | } |
| 120 | return d |
| 121 | } |
| 122 | |
| 123 | func CleanStamps(in []Stamp) []Stamp { |
| 124 | known := map[string]bool{} |
| 125 | for _, s := range Symbols { |
| 126 | known[s.Key] = true |
| 127 | } |
| 128 | var out []Stamp |
| 129 | for _, s := range in { |
| 130 | if s.V < 0 || s.V >= len(Views) || !known[s.S] || math.IsNaN(s.X) || math.IsNaN(s.Y) || |
| 131 | s.X < 0 || s.X > 1 || s.Y < 0 || s.Y > 1 { |
| 132 | continue |
| 133 | } |
| 134 | if !colorRe.MatchString(s.C) { |
| 135 | s.C = "#d32f2f" |
| 136 | } |
| 137 | s.Z = max(1, min(3, s.Z)) |
| 138 | out = append(out, s) |
| 139 | if len(out) == 500 { |
| 140 | break |
| 141 | } |
| 142 | } |
| 143 | return out |
| 144 | } |
| 145 | |
| 146 | // ---------- Darstellung ---------- |
| 147 | |
| 148 | // TableRows liefert die Zeilen für das Formular plus leere Zeilen zum Ausfüllen. |
| 149 | func (d Data) TableRows(t *Table, blank int) []Row { |
| 150 | rows := append([]Row{}, d.Tables[t.Key]...) |
| 151 | for range blank { |
| 152 | rows = append(rows, Row{}) |
| 153 | } |
| 154 | return rows |
| 155 | } |
| 156 | |
| 157 | type StampView struct { |
| 158 | X, Y, Size float64 |
| 159 | Char, Color, Name string |
| 160 | } |
| 161 | |
| 162 | func symbol(key string) Symbol { |
| 163 | for _, s := range Symbols { |
| 164 | if s.Key == key { |
| 165 | return s |
| 166 | } |
| 167 | } |
| 168 | return Symbols[0] |
| 169 | } |
| 170 | |
| 171 | // StampSize in viewBox-Einheiten (Bilder sind ca. 80 px breit). |
| 172 | func StampSize(z int) float64 { return []float64{0, 7, 11, 16}[max(1, min(3, z))] } |
| 173 | |
| 174 | func (d Data) StampsFor(view int) []StampView { |
| 175 | v := Views[view] |
| 176 | var out []StampView |
| 177 | for _, s := range d.Stamps { |
| 178 | if s.V != view { |
| 179 | continue |
| 180 | } |
| 181 | sym := symbol(s.S) |
| 182 | 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}) |
| 183 | } |
| 184 | return out |
| 185 | } |
| 186 | |
| 187 | // UsedSymbols für die Legende im Druck. |
| 188 | func (d Data) UsedSymbols() []Symbol { |
| 189 | used := map[string]bool{} |
| 190 | for _, s := range d.Stamps { |
| 191 | used[s.S] = true |
| 192 | } |
| 193 | var out []Symbol |
| 194 | for _, s := range Symbols { |
| 195 | if used[s.Key] { |
| 196 | out = append(out, s) |
| 197 | } |
| 198 | } |
| 199 | return out |
| 200 | } |
| 201 | |
| 202 | // ---------- Import der alten Befundseite ---------- |
| 203 | |
| 204 | // Legacy ist das Exportformat der früheren Befundseite (Befund_*.json). |
| 205 | type Legacy struct { |
| 206 | Name, Date, Dob string |
| 207 | Data Data |
| 208 | Sessions []Row // date, goal, note |
| 209 | } |
| 210 | |
| 211 | // ParseLegacy erkennt alte Exporte an den flachen data-bind-Schlüsseln. |
| 212 | func ParseLegacy(b []byte) (Legacy, error) { |
| 213 | var raw map[string]json.RawMessage |
| 214 | if err := json.Unmarshal(b, &raw); err != nil { |
| 215 | return Legacy{}, fmt.Errorf("kein gültiges JSON: %w", err) |
| 216 | } |
| 217 | if _, ok := raw["name"]; !ok { |
| 218 | if _, ok := raw["rom"]; !ok { |
| 219 | return Legacy{}, fmt.Errorf("keine Befund-Datei der alten Befundseite") |
| 220 | } |
| 221 | } |
| 222 | str := func(k string) string { |
| 223 | var s string |
| 224 | if json.Unmarshal(raw[k], &s) == nil { |
| 225 | return strings.TrimSpace(s) |
| 226 | } |
| 227 | var bl bool |
| 228 | if json.Unmarshal(raw[k], &bl) == nil && bl { |
| 229 | return "1" |
| 230 | } |
| 231 | return "" |
| 232 | } |
| 233 | l := Legacy{Name: str("name"), Date: str("date"), Dob: str("dob"), Data: New()} |
| 234 | for k := range fieldIndex { |
| 235 | if v := str(k); v != "" { |
| 236 | l.Data.Fields[k] = v |
| 237 | } |
| 238 | } |
| 239 | // Eigene Zusatzabschnitte hießen custom-xxxx (Überschrift wurde nie gespeichert) |
| 240 | var extra []string |
| 241 | keys := make([]string, 0, len(raw)) |
| 242 | for k := range raw { |
| 243 | keys = append(keys, k) |
| 244 | } |
| 245 | sort.Strings(keys) |
| 246 | for _, k := range keys { |
| 247 | if strings.HasPrefix(k, "custom-") { |
| 248 | if v := str(k); v != "" { |
| 249 | extra = append(extra, v) |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | if len(extra) > 0 { |
| 254 | l.Data.Fields["zusatz"] = strings.TrimSpace(l.Data.Fields["zusatz"] + "\n\n" + strings.Join(extra, "\n\n")) |
| 255 | } |
| 256 | rowsOf := func(k string) []Row { |
| 257 | var in []map[string]any |
| 258 | if err := json.Unmarshal(raw[k], &in); err != nil { |
| 259 | return nil // Feld fehlt oder hat ein anderes Format |
| 260 | } |
| 261 | var out []Row |
| 262 | for _, m := range in { |
| 263 | r := Row{} |
| 264 | for ck, cv := range m { |
| 265 | if s := strings.TrimSpace(fmt.Sprint(cv)); s != "" && cv != nil { |
| 266 | r[ck] = s |
| 267 | } |
| 268 | } |
| 269 | if len(r) > 0 { |
| 270 | out = append(out, r) |
| 271 | } |
| 272 | } |
| 273 | return out |
| 274 | } |
| 275 | for _, t := range Tables { |
| 276 | if rs := rowsOf(t.Key); len(rs) > 0 { |
| 277 | l.Data.Tables[t.Key] = rs |
| 278 | } |
| 279 | } |
| 280 | l.Sessions = rowsOf("treat") |
| 281 | return l, nil |
| 282 | } |
| 283 | |
| 284 | // ---------- Vergleich zweier Befunde ---------- |
| 285 | |
| 286 | type FieldDiff struct{ Section, Label, A, B string } |
| 287 | |
| 288 | type RowDiff struct { |
| 289 | Key string |
| 290 | Cells []CellDiff |
| 291 | } |
| 292 | |
| 293 | type CellDiff struct{ Label, A, B, Delta string } |
| 294 | |
| 295 | type TableDiff struct { |
| 296 | Title string |
| 297 | Rows []RowDiff |
| 298 | } |
| 299 | |
| 300 | func Compare(a, b Data) ([]FieldDiff, []TableDiff) { |
| 301 | var fields []FieldDiff |
| 302 | for _, s := range Sections { |
| 303 | for _, p := range s.Parts { |
| 304 | for _, f := range p.Fields { |
| 305 | va, vb := a.Fields[f.Key], b.Fields[f.Key] |
| 306 | if va == vb { |
| 307 | continue |
| 308 | } |
| 309 | label := f.Label |
| 310 | if label == "" { |
| 311 | label = s.Title |
| 312 | if p.Title != "" { |
| 313 | label = p.Title |
| 314 | } |
| 315 | } |
| 316 | if f.Type == "check" { |
| 317 | va, vb = yesNo(va), yesNo(vb) |
| 318 | } |
| 319 | fields = append(fields, FieldDiff{Section: s.Title, Label: label, A: va, B: vb}) |
| 320 | } |
| 321 | } |
| 322 | } |
| 323 | titles := map[string]string{ |
| 324 | "rom": "ROM", "mfp": "MFP", "circ": "Umfang", "tests": "Spezifische Tests", |
| 325 | "measures": "Assessments & PROMs", "psfs": "PSFS", |
| 326 | } |
| 327 | var tables []TableDiff |
| 328 | for _, t := range Tables { |
| 329 | if len(t.Match) == 0 { |
| 330 | continue |
| 331 | } |
| 332 | td := TableDiff{Title: titles[t.Key]} |
| 333 | key := func(r Row) string { |
| 334 | parts := make([]string, len(t.Match)) |
| 335 | for i, m := range t.Match { |
| 336 | parts[i] = r[m] |
| 337 | } |
| 338 | return strings.Join(parts, " · ") |
| 339 | } |
| 340 | ra, rb := map[string]Row{}, map[string]Row{} |
| 341 | var order []string |
| 342 | for _, r := range a.Tables[t.Key] { |
| 343 | k := key(r) |
| 344 | if _, seen := ra[k]; !seen { |
| 345 | order = append(order, k) |
| 346 | } |
| 347 | ra[k] = r |
| 348 | } |
| 349 | for _, r := range b.Tables[t.Key] { |
| 350 | k := key(r) |
| 351 | if _, seenA := ra[k]; !seenA { |
| 352 | if _, seenB := rb[k]; !seenB { |
| 353 | order = append(order, k) |
| 354 | } |
| 355 | } |
| 356 | rb[k] = r |
| 357 | } |
| 358 | for _, k := range order { |
| 359 | rd := RowDiff{Key: k} |
| 360 | for _, c := range t.Cols { |
| 361 | if !contains(t.Diff, c.Key) { |
| 362 | continue |
| 363 | } |
| 364 | va, vb := ra[k][c.Key], rb[k][c.Key] |
| 365 | if va == "" && vb == "" { |
| 366 | continue |
| 367 | } |
| 368 | rd.Cells = append(rd.Cells, CellDiff{Label: c.Label, A: va, B: vb, Delta: Delta(va, vb)}) |
| 369 | } |
| 370 | if len(rd.Cells) > 0 { |
| 371 | td.Rows = append(td.Rows, rd) |
| 372 | } |
| 373 | } |
| 374 | if len(td.Rows) > 0 { |
| 375 | tables = append(tables, td) |
| 376 | } |
| 377 | } |
| 378 | return fields, tables |
| 379 | } |
| 380 | |
| 381 | var numRe = regexp.MustCompile(`-?\d+(?:[.,]\d+)?`) |
| 382 | |
| 383 | // Delta vergleicht Zahlen positionsweise: "0-0-120" → "0-0-135" ergibt "+15" |
| 384 | // (nur geänderte Positionen), "3" → "4" ergibt "+1". |
| 385 | func Delta(a, b string) string { |
| 386 | // Neutral-Null-Notation: Bindestriche sind Trenner, keine Vorzeichen |
| 387 | norm := func(s string) []float64 { |
| 388 | s = strings.ReplaceAll(s, "–", "-") |
| 389 | var out []float64 |
| 390 | for _, m := range numRe.FindAllString(strings.ReplaceAll(s, "-", " "), -1) { |
| 391 | f, err := strconv.ParseFloat(strings.ReplaceAll(m, ",", "."), 64) |
| 392 | if err == nil { |
| 393 | out = append(out, f) |
| 394 | } |
| 395 | } |
| 396 | return out |
| 397 | } |
| 398 | na, nb := norm(a), norm(b) |
| 399 | if len(na) == 0 || len(na) != len(nb) { |
| 400 | return "" |
| 401 | } |
| 402 | var parts []string |
| 403 | for i := range na { |
| 404 | d := nb[i] - na[i] |
| 405 | if d == 0 { |
| 406 | continue |
| 407 | } |
| 408 | s := strconv.FormatFloat(d, 'f', -1, 64) |
| 409 | if d > 0 { |
| 410 | s = "+" + s |
| 411 | } |
| 412 | parts = append(parts, strings.ReplaceAll(s, ".", ",")) |
| 413 | } |
| 414 | if len(parts) == 0 { |
| 415 | return "±0" |
| 416 | } |
| 417 | return strings.Join(parts, " / ") |
| 418 | } |
| 419 | |
| 420 | func yesNo(v string) string { |
| 421 | if v != "" { |
| 422 | return "ja" |
| 423 | } |
| 424 | return "nein" |
| 425 | } |
| 426 | |
| 427 | func contains(s []string, v string) bool { |
| 428 | for _, x := range s { |
| 429 | if x == v { |
| 430 | return true |
| 431 | } |
| 432 | } |
| 433 | return false |
| 434 | } |
| 435 | |
| 436 | // PSFSMean: Mittelwert der PSFS-Aktivitäten (leer, wenn keine Werte). |
| 437 | func PSFSMean(d Data) string { |
| 438 | var sum float64 |
| 439 | var n int |
| 440 | for _, r := range d.Tables["psfs"] { |
| 441 | if f, err := strconv.ParseFloat(strings.ReplaceAll(r["score"], ",", "."), 64); err == nil { |
| 442 | sum += f |
| 443 | n++ |
| 444 | } |
| 445 | } |
| 446 | if n == 0 { |
| 447 | return "" |
| 448 | } |
| 449 | return strings.ReplaceAll(strconv.FormatFloat(sum/float64(n), 'f', 1, 64), ".", ",") |
| 450 | } |
| 451 | |
| 452 | func (d Data) StampsJSON() string { |
| 453 | if d.Stamps == nil { |
| 454 | return "[]" |
| 455 | } |
| 456 | b, _ := json.Marshal(d.Stamps) |
| 457 | return string(b) |
| 458 | } |
| 459 |