data.go
⎇
Raw
1package befund
2
3import (
4 "encoding/json"
5 "fmt"
6 "math"
7 "net/url"
8 "regexp"
9 "sort"
10 "strconv"
11 "strings"
12)
13
14type 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.
18type 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
27type Data struct {
28 Fields map[string]string `json:"fields"`
29 Tables map[string][]Row `json:"tables"`
30 Stamps []Stamp `json:"stamps"`
31}
32
33func New() Data { return Data{Fields: map[string]string{}, Tables: map[string][]Row{}} }
34
35func 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
52func (d Data) JSON() string {
53 b, _ := json.Marshal(d)
54 return string(b)
55}
56
57func (d Data) Get(k string) string { return d.Fields[k] }
58
59// HasSection: enthält die Sektion irgendeinen Wert? (Fachbereiche aufklappen, Druck filtern)
60func (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
69func (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
83var 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.
87func 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
123func 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.
149func (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
157type StampView struct {
158 X, Y, Size float64
159 Char, Color, Name string
160}
161
162func 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).
172func StampSize(z int) float64 { return []float64{0, 7, 11, 16}[max(1, min(3, z))] }
173
174func (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.
188func (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).
205type 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.
212func 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
286type FieldDiff struct{ Section, Label, A, B string }
287
288type RowDiff struct {
289 Key string
290 Cells []CellDiff
291}
292
293type CellDiff struct{ Label, A, B, Delta string }
294
295type TableDiff struct {
296 Title string
297 Rows []RowDiff
298}
299
300func 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
381var 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".
385func 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
420func yesNo(v string) string {
421 if v != "" {
422 return "ja"
423 }
424 return "nein"
425}
426
427func 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).
437func 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
452func (d Data) StampsJSON() string {
453 if d.Stamps == nil {
454 return "[]"
455 }
456 b, _ := json.Marshal(d.Stamps)
457 return string(b)
458}
459