diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..e8f8d41 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173, + "autoPort": true + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index 58f95d8..65ea664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,10 +9,30 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh ## Stack - React 18 + TypeScript + Vite (PWA) -- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js` +- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`; Brandfarbe als `@theme { --color-brand }` in `src/index.css` - Dexie.js v5 — IndexedDB-Wrapper, kein Backend - React Router v6 -- Capgemini-Brandfarbe: `#0070AD` +- Capgemini-Brandfarbe: `#0070AD` (Tailwind-Klasse `brand`, JS-Konstante `BRAND_COLOR` in `src/config/constants.ts`) + +## Projektstruktur (nach Architektur-Refactoring, Stand 2026-07-02) + +``` +src/ + config/constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP + db/ + index.ts ← reiner Re-Export (types + schema + seeds) + types.ts ← alle Interfaces, FeedbackRating, AppSettings + schema.ts ← Dexie-Klasse + Migrationsversionen + seeds/ ← assignmentTypes.ts, feedbackStructure.ts, migrations.ts + pages/ + meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx + RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden) + ...übrige Pages unverändert am alten Ort + utils/ + ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung (genutzt von Evaluation.tsx & AssignmentFeedbackPage.tsx) +``` + +Bestehende Imports aus `'../db'` funktionieren weiterhin unverändert (Re-Export). ## Entwicklung @@ -28,8 +48,9 @@ npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sei ### Doppelte `criteriaId`-Semantik ``` -Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung) -ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung) +Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung) +ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung pro Protokoll-Eintrag) +ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriterium-Zuordnung einer einzelnen Zeile innerhalb eines ConversationEntry.note-Blocks, seit 2026-07-02 — bewusst anders benannt als "criteriaId" um obige Verwechslungsgefahr nicht fortzuschreiben) ``` ### AssignmentType.criteriaIds @@ -54,6 +75,10 @@ type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'ne Aggregation: gewichteter Durchschnitt → `avg < 1.5` Not · `< 2.5` Partially · `< 3.5` Nearly · `≥ 3.5` Fully +**Bug-Klasse, auf die immer prüfen:** Filter der Form `a.score !== null` schließen N/A NICHT aus (N/A ist der String `'na'`, nicht `null`!). Richtig ist immer `a.score !== null && a.score !== 'na'`. Gefunden und gefixt in `Evaluation.tsx` (2026-07-02); dieselbe Prüfung nötig überall, wo `Assessment.score` oder `ConversationSkillScore.score` gemittelt wird. + +**Zweite Bug-Klasse, auf die immer prüfen (Kriterium-Gewichtung):** `FeedbackCriterionItem.weight` geht seit 2026-07-03 von `0,0` bis `2,0` (Config → Feedback, vorher grobe `×1`–`×5`-Stufen). Jede gewichtete Mittelung MUSS normieren — `Σ(score×weight) / Σ(weight)`, niemals `score×weight` direkt als Endwert interpretieren (würde die Bewertungsstufe verzerren, z.B. Score 3 „Nearly" × Gewicht 0,5 = 1,5, was fälschlich zwischen Not/Partially läge). Zusätzlich: wenn `Σweight === 0` (z.B. alle beteiligten Kriterien einer Kategorie auf Gewicht 0 gesetzt), **nicht** dividieren — `NaN < 1.5/2.5/3.5` ist überall `false` und fällt sonst still auf den letzten Bucket „Fully" durch. Immer `if (wSum === 0) return null` (bzw. `continue`) vor der Division. Umgesetzt in `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`) — beim nächsten neuen gewichteten Durchschnitt genauso prüfen. + ### Routing ``` @@ -66,7 +91,15 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()` - `MeetingInstance.generalNotes` kann `undefined` sein → immer `?? ''` als Fallback - `PhaseKey` ist `string`, kein Union-Typ -- Tailwind v4: arbitrary values `bg-[#0070AD]` direkt im className — kein Config nötig +- Tailwind v4: Brandfarbe ist `brand` (Tailwind-Klasse, via `@theme` in `index.css`) — **kein** `bg-[#0070AD]` mehr neu schreiben +- Auswertungslogik greift auf **zwei komplett unabhängige Bewertungs-Strukturen** zu, die nicht automatisch synchron sind: + - `Assessment` (Tabelle `assessments`) — pro Meeting erfasste Kriterium-Bewertungen, Basis für `/evaluation` + - `FeedbackCategoryRating` — ausschließlich manuell auf `AssignmentFeedbackPage` gesetzt, unabhängig von Meeting-Daten, Basis für das finale Abschluss-Feedback + - Seit 2026-07-02: `src/utils/ratingTrend.ts` berechnet aus den `Assessment`-Daten einen **zeitgewichteten Vorschlag** pro Kategorie (spätere Meetings zählen stärker, Gewichtungsfaktor konfigurierbar unter Config → Gewichtung) inkl. Trend-Erkennung (Verbesserung/Verschlechterung). Der Vorschlag wird auf `AssignmentFeedbackPage` als Badge angezeigt und bleibt frei überschreibbar — die zwei Strukturen bleiben bewusst getrennt. +- Meeting-Filter bei Aggregationen über mehrere Meetings: immer `!m.deletedAt && m.status === 'done'` — sonst fließen Papierkorb-/unfertige Meetings mit ein +- **Weichmacher/Füllwörter in Formulierungen** (z.B. "ein bisschen") werden bewusst **nicht** über eine eigene Text-Erkennungslogik behandelt — stattdessen legt man dafür ein eigenes, niedrig gewichtetes Kriterium an (z.B. "Sprachliche Präzision", Gewicht < 1) und taggt/notiert entsprechende Zeilen ganz normal (meist mit `(!)`). Die bestehende Tagging- + Notation-Vorschlag-Mechanik deckt das automatisch ab. +- Der `äh`-Zähler (`ConversationEntry.fillerCount`) bleibt bewusst **rein informativ** — keine automatische Schwellenwert-Bewertung. Würde eine eigene Design-Entscheidung brauchen (ab wann gilt die Zahl als "zu hoch"), aktuell reicht die reine Anzeige als Grundlage für die manuelle Bewertung. +- `ConversationEntry` entspricht weiterhin **einem ganzen Sprecher-Turn** (ein zusammenhängender, mehrzeiliger Text-Block bis "✓ Schließen") — bewusst so belassen, ein Zwischenstand mit "ein Entry pro Zeile" wurde am 2026-07-02 wieder verworfen, weil er die Lesbarkeit des Protokolls zerstört hat. Kriterium-Zuordnung passiert stattdessen **pro Zeile innerhalb** eines Entry über `ConversationEntry.lineTags` (Matching per exaktem Zeilentext, nicht per Index — robust gegenüber nachträglichem Einfügen/Löschen von Zeilen). Zeilen splitten immer über `splitNoteLines()` in `src/utils/notationParser.tsx`, nicht erneut `note.split('\n')` inline schreiben (sonst laufen Trim-/Filter-Verhalten auseinander). --- @@ -83,13 +116,17 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()` | Prio | Aufgabe | Datei | |---|---|---| -| M | `Evaluation.tsx` auf neue Datenstruktur migrieren | `src/pages/Evaluation.tsx` | -| M | AI-Prompt auf `feedbackCriterionItems` umstellen | `src/pages/AssignmentFeedbackPage.tsx` | -| M | `meetingExport.ts` — Kriterien-Auflösung anpassen | `src/utils/meetingExport.ts` | +| H | N/A-Aggregationsbug fixen (siehe Bug-Klasse oben) | `src/pages/AssignmentFeedbackPage.tsx`, `src/pages/FeedbackDraftPage.tsx` | +| H | **KI-Generierung derzeit funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich blockiert das Capgemini-Netzwerk/Firewall den Call zu `openrouter.ai`. Root Cause noch nicht untersucht — ggf. mit internem IT-Support klären. | `src/pages/AssignmentFeedbackPage.tsx` | +| H | **Größeres Vorhaben (noch nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem — Ablösung des aktuellen Einzel-Prompts in `buildAiPrompt`. Braucht eigene Design-Session. | `src/pages/AssignmentFeedbackPage.tsx` | +| M | `FeedbackDraftPage.tsx` auf neue Datenstruktur migrieren (nutzt noch `categories`/`criteria`) | `src/pages/FeedbackDraftPage.tsx` | +| M | `db/queries/`-Layer einführen (Vorbereitung SQLite-Migration, DB-Calls aus Komponenten extrahieren) | `src/db/queries/` (neu) | +| M | **Selbstlernendes Tagging (Idee, noch nicht gescoped):** Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten (ähnliche Formulierungen → gleiches Kriterium). Braucht eigene Design-Session (Ähnlichkeits-/Lernmechanik, Datenbasis, UI). | `src/pages/meeting/CriterionTagPicker.tsx` | | N | Dashboard: Assignment-Nummer anzeigen | `src/pages/Dashboard.tsx` | -| N | AssignmentCreate: Feld für Assignment-Nummer | `src/pages/AssignmentCreate.tsx` | | N | Toter Code in FeedbackStructureConfig entfernen | `src/pages/FeedbackStructureConfig.tsx` | -| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/index.ts` | +| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/schema.ts` | + +**Erledigt (2026-07-02):** Konfiguration zentralisiert (`config/constants.ts`), `db/index.ts` in types/schema/seeds aufgeteilt, `MeetingView.tsx` in 3 Dateien aufgeteilt (`pages/meeting/`), `Evaluation.tsx` auf `feedbackCriterionItems` migriert + nach Assignment gruppiert + Papierkorb/Status-Filter + N/A-Bug gefixt, `zustand` entfernt (war ungenutzt). Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung umgesetzt (`src/utils/ratingTrend.ts`, neue Tabelle `appSettings`, neuer Config-Tab „Gewichtung“, Vorschlags-Badge + Übernehmen/Trend-in-Text auf `AssignmentFeedbackPage`, `buildAiPrompt` um Trend-Abschnitt erweitert und Legacy-`db.criteria`-Bug dabei gefixt). Kriterium-Zuordnung für Freitext-Notizen umgesetzt: `ConversationEntry.lineTags` (Zeilentext → Kriterium, nach einem verworfenen Zwischenstand mit "ein Entry pro Zeile" — siehe Sonstige Fallstricke), `CriterionTagPicker.tsx` (Chip+Popover mit Suche, unverändert seit erster Version) wird pro Zeile direkt an der Zeile via `NotationText`s neuer optionaler `renderLineAddon`-Prop gerendert; Zuordnung passiert bewusst nur nachträglich (nicht live während des Tippens), um den Schreibfluss nicht zu stören. `buildAiPrompt` führt Assessment-Notizen und getaggte Zeilen jetzt pro Kriterium zusammen, unzugeordnete Zeilen bleiben im allgemeinen Protokoll-Block. **(2026-07-03)** Vier Folge-Fixes: `meetingExport.ts` löste Kriterien über die Legacy-Tabelle `db.criteria` auf (identischer Bug wie der frühere `buildAiPrompt`-Fix) — dadurch war die Gesamtbewertung im Export faktisch leer; jetzt auf `feedbackCriterionItems` umgestellt, Markdown-Export zeigt zusätzlich das Kriterium pro getaggter Protokollzeile. `dbBackup.ts`s `TABLE_NAMES` enthielt `appSettings` nicht — Voll-Backups verloren die Gewichtungs-Config stillschweigend; jetzt ergänzt samt Kommentar, dass neue Tabellen dort manuell nachgezogen werden müssen. Notation → Bewertungs-Vorschlag: `detectNotationRating()` in `notationParser.tsx` (`!`→Not, `(!)`→Partially, `(+)`→Nearly, `+`→Fully) kombiniert mit `lineTags` liefert in `AssessmentTab.tsx` einen "Vorschlag aus Protokoll"-Badge pro Kriterium-Item, unabhängig vom bestehenden "Aus Protokoll"-Button (andere Datenquelle). `AssignmentCreate.tsx`: Assignment-Nummer ist jetzt Pflichtfeld (nur auf Formular-Ebene, Typ bleibt optional wegen Altdaten). Kriterium-Gewichtung auf `0,0–2,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1`–`×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig). --- diff --git a/docs/EVALUATION_FEEDBACK_REDESIGN.md b/docs/EVALUATION_FEEDBACK_REDESIGN.md new file mode 100644 index 0000000..df3f111 --- /dev/null +++ b/docs/EVALUATION_FEEDBACK_REDESIGN.md @@ -0,0 +1,51 @@ +# Design-Brief: Zeitgewichteter Bewertungs-Vorschlag (Auswertung & Abschluss-Feedback) + +Status: **Umgesetzt (2026-07-02).** Design-Fragen mit Lars geklärt und implementiert — siehe `CLAUDE.md` (Abschnitt "zwei parallele Bewertungs-Strukturen", Erledigt-Liste) für den aktuellen Stand. Dieses Dokument bleibt als Kontext/Historie der Design-Entscheidungen erhalten. + +--- + +## Ausgangslage + +Am 2026-07-02 wurde bei der Migration von `Evaluation.tsx` auf die neue Datenstruktur (`feedbackCriterionItems`) sichtbar, dass die App **zwei komplett unabhängige, nicht synchronisierte Bewertungs-Strukturen** hat: + +| Struktur | Tabelle | Wo gesetzt | Zweck | +|---|---|---|---| +| Meeting-Assessment | `assessments` | `MeetingView` (`pages/meeting/AssessmentTab.tsx`), pro Meeting, pro Kriterium | Live-Bewertung während/nach jedem Kundentermin | +| Abschluss-Feedback | `feedbackCategoryRatings` | `AssignmentFeedbackPage.tsx`, manuell pro Kategorie | Finales, für den Institutee bestimmtes Feedback am Assignment-Ende | + +Aktuell berechnet `/evaluation` (`Evaluation.tsx`) einen gleichgewichteten Durchschnitt aller `assessments` je Kriterium/Kategorie, gruppiert nach Assignment → Institutee. Das Abschluss-Feedback auf `AssignmentFeedbackPage` ist davon komplett entkoppelt — die Kategorie-Ratings dort werden per Klick frei gesetzt, nicht aus den Meeting-Daten abgeleitet. Der einzige bestehende Brückenschlag ist `buildAiPrompt()` in `AssignmentFeedbackPage.tsx`, der Assessment-Rohdaten in einen KI-Prompt packt (dieser Prompt hat aktuell noch einen offenen Bug: nutzt `db.criteria`, Legacy-Tabelle, sollte `feedbackCriterionItems` nutzen — siehe `docs/STATUS_UND_OFFENE_PUNKTE.md`, Punkt M2). + +## Fachlicher Wunsch (Lars, 2026-07-02, wörtlich sinngemäß) + +> Die Werte sollen aus den einzelnen Meetings berechnet werden — als **Vorschlag**. Dieser Vorschlag kann durch den Feedbackgeber überarbeitet werden. Eine parallele Ansicht ist daher möglicherweise sinnvoll. Bei der Berechnung der Vorschläge auf Kriteriumslevel über alle Meetings sollte auch die **zeitliche Reihenfolge** berücksichtigt werden. Es sollte honoriert werden, wenn sich der Berater über die Laufzeit des Assignments in bestimmten Kriterien **verbessert** hat. **Verschlechterung** über die Zeit sollte ebenfalls berücksichtigt werden (negative Lernkurve). Diese Entwicklung sollte dann auch **im Kommentar an der richtigen Stelle** berücksichtigt werden. + +Kernpunkte: +1. **Zeitgewichtung** — spätere Meetings sollen stärker in den Kriterium-Durchschnitt einfließen als frühere (nicht der aktuelle gleichgewichtete Ansatz). +2. **Trend-Erkennung** — sowohl positive als auch negative Entwicklung über die Assignment-Laufzeit soll erkannt werden. +3. **Automatische Kommentar-Integration** — die erkannte Entwicklung soll an der "richtigen Stelle" im Feedback-Text auftauchen (vermutlich `FeedbackDimensionText.achievements`/`developmentNeeds`, evtl. über den bestehenden KI-Prompt-Mechanismus). +4. **Vorschlag bleibt überschreibbar** — die zwei Strukturen (`Assessment` vs. `FeedbackCategoryRating`) sollen bewusst getrennt bleiben; der berechnete Vorschlag ersetzt nicht die manuelle Entscheidung des Feedbackgebers. + +## Bereits vorhandene Bausteine (wiederverwendbar) + +- `RATING_NUM_MAP` in `src/config/constants.ts` — numerische Skala für Aggregation +- `weightedAvg()` in `src/pages/Evaluation.tsx` — aktueller (ungewichteter nach Zeit) Aggregationsansatz auf Item-/Kategorie-Ebene, als Ausgangspunkt für die zeitgewichtete Variante +- `MeetingInstance.date` — Zeitstempel pro Meeting, Basis für Zeitgewichtung +- `buildAiPrompt()` in `AssignmentFeedbackPage.tsx` — bestehender Mechanismus, der Assessment-Daten in strukturierten Text für die KI übersetzt; möglicher Ort, um Trend-Informationen einzuspeisen +- Bekannter Bug-Pattern, der bei jeder neuen Aggregationslogik zu beachten ist: `score !== null` schließt `'na'` nicht aus — immer `score !== null && score !== 'na'` prüfen (siehe `CLAUDE.md`) + +## Offene Design-Fragen (in der Planungssession zu klären) + +1. **Gewichtungsformel**: Linear nach Meeting-Reihenfolge? Exponentiell abfallend nach Alter? Feste Gewichte (z.B. letztes Meeting zählt doppelt)? Muss robust für Assignments mit 2 bis 7+ Meetings funktionieren. +2. **Trend-Schwellenwert**: Ab wann gilt eine Entwicklung als "signifikant" (z.B. mind. 1 Rating-Stufe Unterschied zwischen erstem und letztem gewerteten Meeting)? Wie viele Meetings mit Bewertung sind mindestens nötig, um überhaupt einen Trend zu behaupten? +3. **Granularität**: Wird der Trend auf Kriterium-Ebene, Kategorie-Ebene oder beidem berechnet? +4. **UI-Darstellung des Vorschlags**: Nebeneinander-Ansicht (Vorschlag vs. aktueller manueller Wert) mit "Übernehmen"-Button? Oder Vorschlag pre-fillt die Rating-Buttons direkt, überschreibbar wie jeder andere Wert? +5. **Kommentar-Generierung**: Deterministisch (Template-Satz wie "Zeigt über die Laufzeit eine Verbesserung in X") oder über den bestehenden KI-Prompt-Mechanismus? Wie wird das mit dem offenen AI-Prompt-Bug (M2) koordiniert? +6. **Code-Ort**: Soll die Trend-Berechnung eine gemeinsame Funktion sein, die sowohl `Evaluation.tsx` als auch `AssignmentFeedbackPage.tsx` nutzen (Vermeidung von Doppel-Logik)? Falls ja, wohin (`src/db/queries/`? neues `src/utils/ratingTrend.ts`?). +7. **Naming/Labeling**: Wie wird in der UI klar unterschieden zwischen "berechneter Vorschlag" und "final vom Feedbackgeber bestätigter Wert"? + +## Betroffene Dateien (voraussichtlich) + +- `src/pages/Evaluation.tsx` — zeigt den Vorschlag an +- `src/pages/AssignmentFeedbackPage.tsx` — übernimmt/überschreibt den Vorschlag, generiert Kommentar +- `src/config/constants.ts` — evtl. neue Konstanten für Gewichtungsparameter +- Neue Datei denkbar: `src/utils/ratingTrend.ts` oder `src/db/queries/ratingSuggestion.ts` für die gemeinsame Berechnungslogik diff --git a/docs/FACHLICHE_ARCHITEKTUR.md b/docs/FACHLICHE_ARCHITEKTUR.md index 3c30667..8e71e7a 100644 --- a/docs/FACHLICHE_ARCHITEKTUR.md +++ b/docs/FACHLICHE_ARCHITEKTUR.md @@ -31,8 +31,9 @@ MeetingInstance ← Ein konkreter Termin (z.B. "Briefing C └── ConversationEntry[] ← Zeitleiste: wer spricht wann └── Assessment[] ← Gesamtbewertung pro Institutee am Ende -ConversationEntry ← Ein Gesprächsbeitrag eines Institutees +ConversationEntry ← Ein zusammenhängender Sprecher-Beitrag (mehrzeilig) eines Institutees └── ConversationSkillScore[] ← Kategorie-Schnellbewertung (während des Beitrags) + └── lineTags[] ← Kriterium-Zuordnung einzelner Zeilen (nachträglich, per Zeilentext) FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback └── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit") @@ -79,7 +80,7 @@ Wiederholbare Phasen (z.B. Alignment Calls) werden mit fortlaufendem `phaseIndex ### Aggregationslogik -**Gewichteter Durchschnitt** über alle Kriterien mit `weight`-Faktor (×1–×5): +**Gewichteter Durchschnitt** über alle Kriterien mit `weight`-Faktor (konfigurierbar `0,0`–`2,0` in `0,1`-Schritten — `0` blendet ein Kriterium komplett aus, z.B. für sprachliche Weichmacher/Füllwörter mit reduziertem Einfluss): ``` avg = Σ(score_i × weight_i) / Σ(weight_i) @@ -99,6 +100,26 @@ Mapping auf Rating: 2. **Kriterium-Level (Gesamtbewertung am Ende des Meetings):** `Assessment.criteriaId` → `FeedbackCriterionItem.id` Detaillierte Bewertung je Kriterium nach dem Meeting. Grundlage für die Kategorie-Farbkodierung im Gesamtbewertungs-Tab. +Verbindendes drittes Element: **Kriterium-Zuordnung einzelner Protokollzeilen** (`ConversationEntry.lineTags`). Der Lead schreibt während des Gesprächs einen zusammenhängenden, mehrzeiligen Beitrag pro Sprecher-Block; nachträglich (nicht während des Tippens, um den Schreibfluss nicht zu stören) kann jede einzelne Zeile über ein kleines Tag-Symbol direkt einem Kriterium zugeordnet werden. Diese Zuordnung ist die Grundlage für den Notation-Vorschlag (siehe unten) und fließt auch strukturiert in den KI-Prompt ein. + +--- + +## Bewertungs-Vorschläge + +Die App leitet an zwei Stellen automatisch einen Bewertungs-Vorschlag ab — beide bleiben reine Vorschläge, die der Lead frei übernehmen oder überschreiben kann. + +### 1. Notation-Vorschlag in der Gesamtbewertung (pro Meeting) + +Die Schnellnotations-Symbole (siehe unten) entsprechen einer Bewertungsstufe: `!` → Not, `(!)` → Partially, `(+)` → Nearly, `+` → Fully. Ist eine notierte Protokollzeile einem Kriterium zugeordnet (siehe oben), errechnet die App daraus einen Vorschlag für die Gesamtbewertung dieses Kriteriums ("Vorschlag aus Protokoll" mit Übernehmen-Button). Bei mehreren getaggten Zeilen zum selben Kriterium wird gemittelt. Jede Kategorie-Kopfzeile zeigt zusätzlich — auch zugeklappt — wie viele ihrer Kriterien einen offenen Vorschlag haben. + +Füllwörter oder sprachliche Weichmacher ("ein bisschen") werden nicht automatisch erkannt, sondern über ein eigenes, niedrig gewichtetes Kriterium abgebildet, das ganz normal getaggt und notiert wird. + +### 2. Zeitgewichteter Vorschlag über die Assignment-Laufzeit (`/evaluation`, Abschluss-Feedback) + +Über mehrere Meetings hinweg gewichtet die App spätere Bewertungen stärker als frühere (Gewichtungsfaktor konfigurierbar unter Config → Gewichtung) und erkennt eine Verbesserung oder Verschlechterung eines Institutees über die Zeit. Der Vorschlag erscheint als Badge auf der Abschluss-Feedback-Seite, mit „Übernehmen" für den Bewertungswert und „Trend in Text übernehmen" für einen automatisch formulierten Hinweis auf die Entwicklung in Achievements/Development Needs. + +Beide Vorschlags-Mechanismen greifen nicht in die manuell gepflegten Endergebnisse ein (`Assessment.score`, `FeedbackCategoryRating`) — sie liefern nur einen Ausgangspunkt. + --- ## Feedback-Struktur (Abschluss-Feedback) @@ -142,8 +163,8 @@ Im Gesprächsprotokoll unterstützt die App eine Schnellnotations-Syntax, die fa **Format:** `{AssignmentNummer}_{Datum}_{PhasenLabel}{-#Nr}.{md|json}` **Beispiel:** `CGI-2024-001_02-07-2026_Alignment Call-2.md` -- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen -- **JSON:** Vollständige Datensicherung eines einzelnen Meetings +- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen — getaggte Protokollzeilen zeigen zusätzlich das zugeordnete Kriterium +- **JSON:** Vollständige Datensicherung eines einzelnen Meetings, inklusive Zeilen-Tags ### Datenbank-Backup Vollständiger Export aller Tabellen als JSON-Datei. Import löscht alle Daten und stellt den gesicherten Stand wieder her. Backup sollte nach jedem Meeting erstellt werden. diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 2d15e06..40f150c 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -4,6 +4,14 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht --- +## Stand & nächste Schritte + +Zuletzt abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,0–2,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`. + +**Vollständige, priorisierte Liste der offenen Punkte:** siehe `CLAUDE.md` → Abschnitt "Offene Punkte (priorisiert)" — das ist die laufend gepflegte Quelle, hier nicht dupliziert. Größere offene Themen, die eine eigene Design-Session brauchen: mehrstufiges Prompt-System für die KI-Generierung, selbstlernendes Tagging. + +--- + ## Worum geht es? **Assignment Monitor** ist eine PWA für Lars Stommer (Capgemini Management Consultant Trainee Program Lead). Die App wird auf dem Smartphone genutzt, um Trainees (intern: „Institutees") während ihrer Kunden-Assignments live zu begleiten und zu bewerten. Lars führt die Meetings, beobachtet die Trainees und möchte sofort Notizen und Bewertungen erfassen, ohne Stift/Papier. @@ -34,8 +42,9 @@ Aggregation: **gewichteter Durchschnitt** (avg < 1.5 → Not, < 2.5 → Partiall ### Schlüssel-Unterschied: zwei `criteriaId`-Semantiken ``` -Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting) -ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch) +Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting) +ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch) +ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriterium-Zuordnung einer Protokoll-Zeile, bewusst anders benannt als "criteriaId") ``` Das klingt inkonsistent — ist aber so gewollt und funktioniert so. @@ -70,12 +79,12 @@ const filteredCats = itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) /assignment/new Neues Assignment /assignment/:id AssignmentDetail /assignment/:id/edit AssignmentEdit -/assignment/:id/meeting/:meetingId MeetingView ← WICHTIG: meetingId != assignmentId +/assignment/:id/meeting/:meetingId MeetingView (src/pages/meeting/) ← WICHTIG: meetingId != assignmentId /assignment/:id/feedback/:instituteeId AssignmentFeedbackPage /assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy) -/evaluation Auswertung (veraltet!) +/evaluation Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, nach Assignment gruppiert /consultants Berater -/config Konfiguration (3 Tabs) +/config Konfiguration (4 Tabs: Feedback | Ass.-Typen | Gewichtung | Backup) ``` MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useParams()` @@ -83,7 +92,7 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP ## Konfigurationsbereich **Tab "Feedback"** (`FeedbackStructureConfig.tsx`): -- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung ×1–×5) +- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung `0,0–2,0` in `0,1`-Schritten, seit 2026-07-03 — vorher grobe `×1`–`×5`-Stufen) - Nur Feedback-Struktur, kein AssignmentType-CRUD mehr! **Tab "Ass.-Typen"** (`AssignmentTypeConfig.tsx`): @@ -91,6 +100,9 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP - Pro Typ: Sub-Tabs "Phasen" und "Kriterien" - Kriterien-Tab: Baumstruktur Dimension → Kategorie → Items (Checkbox, Indeterminate) +**Tab "Gewichtung"** (`RatingWeightConfig.tsx`, seit 2026-07-02): +- Ein Regler für den Zeitgewichtungs-Faktor des Bewertungs-Vorschlags (`AppSettings.ratingTrendWeightStep`, Tabelle `appSettings`) + **Tab "Backup"** (`Configuration.tsx`): - JSON-Export aller Tabellen via `exportFullBackup()` - JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd) @@ -99,16 +111,22 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP ### Sofort-Fixes (einfach) 1. **Dashboard zeigt keine Assignment-Nummer** — `Dashboard.tsx` Zeile ~57: `{a.assignmentNumber && ...{a.assignmentNumber}}` hinzufügen -2. **AssignmentCreate hat kein Nummer-Feld** — Inputfeld für `assignmentNumber` hinzufügen -3. **Toter Code in FeedbackStructureConfig** — State-Variablen `assignmentTypes`, `phaseTemplates`, `showCatalogs`, `showTypeConfig`, `expandedType`, `newTypeName`, `newPhase`, `typeError` + alle Handler `toggleCatalogCategory`, `addAssignmentType`, `updateAssignmentTypeName`, `deleteAssignmentType`, `addPhaseTemplate`, `updatePhaseTemplate`, `deletePhaseTemplate` entfernen +2. **Toter Code in FeedbackStructureConfig** — State-Variablen `assignmentTypes`, `phaseTemplates`, `showCatalogs`, `showTypeConfig`, `expandedType`, `newTypeName`, `newPhase`, `typeError` + alle Handler `toggleCatalogCategory`, `addAssignmentType`, `updateAssignmentTypeName`, `deleteAssignmentType`, `addPhaseTemplate`, `updatePhaseTemplate`, `deletePhaseTemplate` entfernen +3. **N/A-Aggregationsbug**: Filter der Form `a.score !== null` schließen `'na'` nicht aus (N/A ist ein String, kein `null`!) → verfälscht Mittelwerte. Bereits gefixt in `Evaluation.tsx`. Noch offen in `AssignmentFeedbackPage.tsx:63` (buildAiPrompt) und `FeedbackDraftPage.tsx:66`. ### Mittlere Aufgaben -4. **Evaluation.tsx neu schreiben** — nutzt `db.categories` + `db.criteria` (Legacy). Soll `feedbackCriterionItems` + `assessments` nutzen und Rating-Labels zeigen -5. **meetingExport.ts anpassen** — Score-Aufschlüsselung nutzt `db.criteria`, sollte `feedbackCriterionItems` nutzen; Kategorie-Namen im Markdown fehlen -6. **AI-Prompt in AssignmentFeedbackPage** — nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` + neue Assessment-Scores nutzen +4. **FeedbackDraftPage.tsx** — nutzt noch Legacy `categories`/`criteria`, sollte auf `feedbackCriterionItems` migriert werden (analog zu `Evaluation.tsx`); letzter Konsument der Legacy-Tabellen außer den Seeds +5. **`db/queries/`-Layer** — DB-Zugriffe aus Komponenten in domänenspezifische Query-Funktionen extrahieren, bereitet SQLite-Migration vor +6. **KI-Generierung funktionsunfähig** — `generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht. -### Große Aufgaben +### Große Aufgaben (eigene Design-Session nötig) 7. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus. +8. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem. +9. **Selbstlernendes Tagging** — aus bisherigen Zeile-zu-Kriterium-Zuordnungen automatische Tag-Vorschläge beim Tippen ableiten. + +**Erledigt (2026-07-02):** Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung — `src/utils/ratingTrend.ts` berechnet pro Kategorie einen zeitgewichteten Vorschlag aus den Meeting-Assessments (Gewichtungsfaktor konfigurierbar unter Config → Gewichtung, neue Tabelle `appSettings`), erkennt Verbesserung/Verschlechterung über die Laufzeit und zeigt beides als Badge auf `AssignmentFeedbackPage` (mit „Übernehmen" + „Trend in Text übernehmen"). `buildAiPrompt` wurde um einen Trend-Abschnitt erweitert; dabei wurde auch der alte `db.criteria`-Bug mitgefixt. + +**Erledigt (2026-07-03):** Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags`, `CriterionTagPicker.tsx`, nachträglich pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks). Notation → Bewertungs-Vorschlag in der Gesamtbewertung (`detectNotationRating()`, „Vorschlag aus Protokoll"-Badge + Kategorie-weite 💡-Zähler). `meetingExport.ts` und `dbBackup.ts` gefixt (Legacy-`db.criteria`-Bug bzw. fehlende `appSettings`-Tabelle im Backup). Assignment-Nummer ist jetzt Pflichtfeld. Kriterium-Gewichtung auf `0,0–2,0` verfeinert, dabei einen Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten gefunden und gefixt. ## Stil-Regeln (bitte einhalten) @@ -133,7 +151,7 @@ exportFullBackup(): Promise // Download als JSON importFullBackup(file: File): Promise<{ ok: boolean; error?: string }> // Clears + bulkAdd ``` -Alle 20 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen dort ergänzen. +Alle 21 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen dort ergänzen (ein fehlender Eintrag fällt nicht auf, bis jemand ein Backup zurückspielt und Daten fehlen — genau so ist `appSettings` einmal vergessen worden, siehe Kommentar über der Liste). ## Seed-Daten @@ -167,3 +185,5 @@ Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben. 4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld. 5. **`expandedCat`** in `MeetingView`: `Record` — öffnet pro Institutee genau eine Kategorie gleichzeitig 6. **Dexie v5**: `bulkAdd` schlägt fehl wenn IDs schon vorhanden → Backup-Import leert erst alle Tabellen +7. **Gewichtete Mittelwerte**: immer `Σ(score×weight)/Σ(weight)`, nie `score×weight` roh interpretieren; bei `Σweight === 0` (seit Gewicht `0` erlaubt ist) `null`/`continue` statt Division — sonst `NaN < x` überall `false` → fällt auf „Fully" durch. Siehe `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`). +8. **`ConversationEntry`** entspricht einem ganzen Sprecher-Turn (mehrzeiliger Text-Block), nicht einer einzelnen Zeile — ein Zwischenstand mit "ein Entry pro Zeile" wurde verworfen. Kriterium-Zuordnung läuft über `lineTags` (Matching per Zeilentext, nicht Index). diff --git a/docs/STATUS_UND_OFFENE_PUNKTE.md b/docs/STATUS_UND_OFFENE_PUNKTE.md index bc96a7c..ec8cb65 100644 --- a/docs/STATUS_UND_OFFENE_PUNKTE.md +++ b/docs/STATUS_UND_OFFENE_PUNKTE.md @@ -1,6 +1,6 @@ # Projektstatus und offene Punkte — Assignment Monitor -Stand: 2026-07-02 +Stand: 2026-07-03 --- @@ -27,13 +27,24 @@ Stand: 2026-07-02 | Strukturiertes Abschluss-Feedback pro Institutee | `AssignmentFeedbackPage.tsx` | | KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` | | Feedback-Status: Entwurf → Final | `AssignmentFeedbackPage.tsx` | -| Feedback-Dimensionsstruktur CRUD mit Gewichtung ×1–×5 | `FeedbackStructureConfig.tsx` | +| Feedback-Dimensionsstruktur CRUD mit Gewichtung (0,0–2,0 in 0,1-Schritten) | `FeedbackStructureConfig.tsx` | | AssignmentTyp CRUD mit Phasen-Templates | `AssignmentTypeConfig.tsx` | | Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` | | Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` | | Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` | | Berater/Gruppen-Verwaltung | `Consultants.tsx` | | PWA (installierbar, Offline-fähig) | Vite-PWA-Plugin | +| Konfiguration zentralisiert (Farben, Ratings, AI-Settings) | `src/config/constants.ts` | +| `db/index.ts` in types/schema/seeds aufgeteilt (Re-Export bleibt kompatibel) | `src/db/` | +| `MeetingView.tsx` in 3 Dateien aufgeteilt | `src/pages/meeting/` | +| Auswertung auf `feedbackCriterionItems` migriert, nach Assignment gruppiert, Papierkorb/Status gefiltert, N/A-Bug gefixt | `Evaluation.tsx` | +| Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung (konfigurierbarer Gewichtungsfaktor, Vorschlags-Badge auf Feedback-Seite, Trend-Satz-Insertion, KI-Prompt-Erweiterung) | `Evaluation.tsx`, `AssignmentFeedbackPage.tsx`, `src/utils/ratingTrend.ts`, `RatingWeightConfig.tsx` | +| Kriterium-Zuordnung für Freitext-Notizen — pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks (`ConversationEntry.lineTags`, Matching per Zeilentext), Chip+Popover mit Suche direkt an der Zeile, nachträglich im Protokoll; `buildAiPrompt` führt Assessment- und Konversations-Notizen pro Kriterium zusammen | `ConversationTab.tsx`, `CriterionTagPicker.tsx`, `notationParser.tsx`, `MeetingView.tsx`, `AssignmentFeedbackPage.tsx` | +| Notation → Bewertungs-Vorschlag in der Gesamtbewertung: `!`/`(!)`/`(+)`/`+` an getaggten Zeilen ergeben einen Rating-Vorschlag je Kriterium ("Vorschlag aus Protokoll" + Übernehmen), plus Kategorie-Kopfzeile zeigt Anzahl offener Vorschläge (💡 N) auch zugeklappt | `AssessmentTab.tsx`, `notationParser.tsx` (`detectNotationRating`) | +| Meeting-Export (Markdown) zeigt Kriterium-Namen korrekt (`db.criteria`-Legacy-Bug gefixt) + Kriterium pro getaggter Zeile; Voll-Backup deckt wieder alle Tabellen ab (`appSettings` ergänzt) | `meetingExport.ts`, `dbBackup.ts` | +| Assignment-Nummer ist Pflichtfeld beim Anlegen | `AssignmentCreate.tsx` | +| Kriterium-Gewichtung feiner (0,0–2,0 in 0,1-Schritten statt `×1`–`×5`), inkl. Division-durch-Null-Fix bei Gewicht 0 in allen gewichteten Mittelwerten | `FeedbackStructureConfig.tsx`, `AssessmentTab.tsx`, `ratingTrend.ts` | +| Sprecher eines Protokoll-Beitrags nachträglich änderbar (im Bearbeiten-Modus) | `ConversationTab.tsx`, `MeetingView.tsx` | --- @@ -43,25 +54,26 @@ Stand: 2026-07-02 | # | Beschreibung | Datei | Aufwand | |---|---|---|---| -| H1 | **IndexedDB-Robustheit**: Browser kann IndexedDB unter Speicherdruck löschen. Migration zu SQLite WASM + OPFS geplant. Bis dahin: regelmäßige Backups als einzige Sicherheit. | `db/index.ts` | L (Wochen) | -| H2 | **Backup-Reminder fehlt**: Es gibt keine automatische Erinnerung / keinen Hinweis nach abgeschlossenem Meeting, dass ein Backup erstellt werden sollte. | `MeetingView.tsx` | S | +| H1 | **IndexedDB-Robustheit**: Browser kann IndexedDB unter Speicherdruck löschen. Migration zu SQLite WASM + OPFS geplant. Bis dahin: regelmäßige Backups als einzige Sicherheit. | `db/schema.ts` | L (Wochen) | +| H2 | **Backup-Reminder fehlt**: Es gibt keine automatische Erinnerung / keinen Hinweis nach abgeschlossenem Meeting, dass ein Backup erstellt werden sollte. | `pages/meeting/MeetingView.tsx` | S | +| H3 | **N/A-Aggregationsbug**: Filter `a.score !== null` schließt den String `'na'` nicht aus, verfälscht Mittelwerte ("Fantasiewerte"). Gefixt in `Evaluation.tsx`, noch offen in `AssignmentFeedbackPage.tsx:63` und `FeedbackDraftPage.tsx:66`. | `AssignmentFeedbackPage.tsx`, `FeedbackDraftPage.tsx` | XS | +| H6 | **KI-Generierung funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert den Call zu `openrouter.ai`. Root Cause noch nicht untersucht. | `AssignmentFeedbackPage.tsx` | ? | +| H7 | **Prompt-System-Neubau (noch nicht gescoped)**: Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem, löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab. Braucht eigene Design-Session. | `AssignmentFeedbackPage.tsx` | L | ### Priorität Mittel | # | Beschreibung | Datei | Aufwand | |---|---|---|---| -| M1 | **Evaluation.tsx veraltet**: Nutzt die Legacy-`Category`/`Criterion`-Tabellen, nicht die neue `feedbackCriterionItems`-Struktur. Zeigt falsche / keine Daten aus neuen Meetings. | `Evaluation.tsx` | M | -| M2 | **AI-Prompt veraltet**: `AssignmentFeedbackPage` liest `db.criteria` (alt) für Score-Zeilen. Sollte auf `feedbackCriterionItems` + `assessments` umgestellt werden. | `AssignmentFeedbackPage.tsx` | M | -| M3 | **meetingExport.md veraltet**: Kriterien-Scores werden über `db.criteria` aufgelöst, nicht `feedbackCriterionItems`. Kategorie-Namen fehlen im Export. | `meetingExport.ts` | S | +| M4 | **FeedbackDraftPage.tsx veraltet**: Nutzt noch Legacy-`Category`/`Criterion`-Tabellen, analog zum ehemaligen Zustand von `Evaluation.tsx`. Letzter verbleibender Konsument der Legacy-Tabellen außer den Seeds. | `FeedbackDraftPage.tsx` | M | +| M5 | **`db/queries/`-Layer fehlt**: DB-Zugriffe direkt aus Komponenten (`db.*`), keine Abstraktionsschicht. Erschwert die geplante SQLite-Migration (H1). | `src/db/queries/` (neu) | M | +| M6 | **Selbstlernendes Tagging (Idee, noch nicht gescoped)**: Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten. Braucht eigene Design-Session. | `CriterionTagPicker.tsx` | ? | ### Priorität Niedrig | # | Beschreibung | Datei | Aufwand | |---|---|---|---| | N1 | **Dashboard**: Zeigt keine Assignment-Nummer. Nur Titel + Kunde + Typ. | `Dashboard.tsx` | XS | -| N2 | **AssignmentCreate**: Kein Feld für Assignment-Nummer. Muss nachträglich über Edit eingegeben werden. | `AssignmentCreate.tsx` | XS | | N3 | **FeedbackStructureConfig**: Toter Code — Funktionen für AssignmentType-CRUD wurden verschoben, Deklarationen blieben (`assignmentTypes`, `phaseTemplates` State + alle zugehörigen Handler). | `FeedbackStructureConfig.tsx` | XS | -| N4 | **Evaluation-Route**: Zeigt Scores als Dezimalzahlen `/5` mit alter Skala statt neuer Rating-Labels. | `Evaluation.tsx` | S | | N5 | **AssignmentCreate**: Erstellt keine `instituteeEnrollments`. Von/Bis-Daten können nur über Edit gepflegt werden. | `AssignmentCreate.tsx` | S | | N6 | **Meeting-Route**: URL ist `/assignment/:id/meeting/:meetingId` — `:id` ist die Assignment-ID, aber die Assignment-Nummer (z.B. CGI-2024-001) ist nicht in der URL. Akzeptiert als nice-to-have. | `App.tsx` | M | @@ -75,11 +87,19 @@ Stand: 2026-07-02 **Vorteil:** Echte Datei auf Gerät, nicht löschbar wie IndexedDB. Kein Server nötig. **Status:** Konzept beschlossen. Implementierung steht aus. Paralleles Backup-System ist die aktuelle Notlösung. -### Evaluation neu (M1 oben) -Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen: -- Pro Institutee: aggregierte Kriterien-Scores über alle Meetings -- Trend über mehrere Assignments -- Vergleich zwischen Institutees +### Evaluation — Zeitgewichteter Vorschlag (erledigt, 2026-07-02) +Vollständig umgesetzt, siehe `docs/EVALUATION_FEEDBACK_REDESIGN.md` für die Design-Entscheidungen: +- Kategorie-Scores werden über `src/utils/ratingTrend.ts` zeitgewichtet gemittelt (Rang = Reihenfolge der tatsächlich erfolgten Bewertungen dieser Kategorie, nicht fester Meeting-Index); Gewichtungsfaktor konfigurierbar unter Config → Gewichtung (`RatingWeightConfig.tsx`, Tabelle `appSettings`) +- Trend (Verbesserung/Verschlechterung) wird ab 2 bewerteten Meetings pro Kategorie erkannt (Delta ≥ 0,5 Punkte) +- Vorschlags-Badge auf `AssignmentFeedbackPage` dauerhaft sichtbar neben dem manuellen Rating, mit „Übernehmen“ und „Trend in Text übernehmen" (fügt deterministischen Satz in Achievements/Development Needs ein) +- `buildAiPrompt` um Trend-Abschnitt erweitert; dabei auch den Legacy-`db.criteria`-Bug (ehem. M2) gefixt +- `Assessment`- und `FeedbackCategoryRating`-Daten bleiben bewusst getrennte, unabhängige Strukturen — der Vorschlag ist rein additiv + +### Kriterium-Tagging + Notation-Vorschlag (erledigt, 2026-07-03) +- `ConversationEntry.lineTags: { text, criterionItemId }[]` ordnet einzelne Zeilen innerhalb eines Sprecher-Blocks einem Kriterium zu (Matching per exaktem Zeilentext, nicht Index — überlebt nachträgliches Einfügen/Löschen von Zeilen). Ein früherer Zwischenstand mit "ein Entry pro Zeile" wurde verworfen, weil er die Protokoll-Lesbarkeit zerstört hat. +- `detectNotationRating()` (`notationParser.tsx`) mappt `!`/`(!)`/`(+)`/`+` auf eine Bewertungsstufe; kombiniert mit getaggten Zeilen liefert `AssessmentTab.tsx` einen Rating-Vorschlag pro Kriterium-Item ("Vorschlag aus Protokoll"), unabhängig von der bestehenden `ConversationSkillScore`-Kategorie-Schnellbewertung. +- Füllwörter/Weichmacher-Formulierungen werden bewusst nicht über eigene Text-Erkennung behandelt, sondern über ein niedrig gewichtetes Kriterium + normales Tagging abgedeckt. +- Kriterium-Gewichtung dabei auf `0,0–2,0` (0,1-Schritte) verfeinert; ein latenter Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten (`catMode`, `ratingTrend.ts`) wurde dabei gefunden und gefixt. ### Dashboard-Verbesserungen (N1 oben) - Assignment-Nummer anzeigen @@ -95,4 +115,5 @@ Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen: | `criteriaIds` auf `AssignmentType` | Referenziert `FeedbackCriterionItem.id`. Wenn Kriterien gelöscht werden, bleiben verwaiste IDs in `criteriaIds`. Filter ignoriert fehlende IDs stillschweigend. | Beim Löschen von Items: `criteriaIds` in allen AssignmentTypes bereinigen | | `Assessment.criteriaId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint in IndexedDB. | Beim Löschen von Items: zugehörige Assessments löschen | | `ConversationSkillScore.criteriaId` | Referenziert `FeedbackCategory.id`. Gleiche Gefahr. | Beim Löschen von Kategorien: ConversationSkillScores bereinigen | +| `ConversationEntry.lineTags[].criterionItemId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint — beim Löschen eines Kriterium-Items bleiben Zeilen-Tags verwaist (Anzeige zeigt dann kein Kriterium mehr, aber Datensatz bleibt). | Beim Löschen von Items: `lineTags` in betroffenen `conversationEntries` bereinigen | | Typ-Wechsel bei Assignment | Zeigt Warnung, löscht aber keine alten Meeting-Bewertungen. Bewertungen mit IDs aus dem alten Typ-Kriteriensatz bleiben erhalten, werden aber möglicherweise nicht mehr angezeigt. | Dokumentiertes Verhalten, absichtlich so gestaltet | diff --git a/docs/TECHNISCHE_ARCHITEKTUR.md b/docs/TECHNISCHE_ARCHITEKTUR.md index 6d49296..4a907df 100644 --- a/docs/TECHNISCHE_ARCHITEKTUR.md +++ b/docs/TECHNISCHE_ARCHITEKTUR.md @@ -17,29 +17,44 @@ --- -## Projektstruktur +## Projektstruktur (aktualisiert nach Architektur-Refactoring, 2026-07-02) ``` src/ + config/ + constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP, RATING_TREND_DELTA_THRESHOLD db/ - index.ts ← Dexie DB-Klasse, alle Interfaces, Seed-Funktionen, Migrations + index.ts ← reiner Re-Export von types/schema/seeds (bestehende Imports bleiben kompatibel) + types.ts ← alle Interfaces, FeedbackRating-Typ + schema.ts ← Dexie DB-Klasse + alle Migrationsversionen + seeds/ + assignmentTypes.ts ← seedIfEmpty() — 3 AssignmentTypen + Legacy-Kriterien + feedbackStructure.ts ← seedFeedbackStructureIfEmpty() — Dimensionen/Kategorien/Items + migrations.ts ← seedCriterionMappingsIfEmpty(), migrateToUnifiedCriteriaIfNeeded() + index.ts ← Re-Export der Seed-Funktionen pages/ Dashboard.tsx ← Assignment-Liste (Home) AssignmentCreate.tsx ← Neues Assignment anlegen AssignmentDetail.tsx ← Phasen-Flow, Meeting-Übersicht, Bewertungs-Chips AssignmentEdit.tsx ← Assignment nachträglich bearbeiten - MeetingView.tsx ← Live-Meeting: Protokoll + Gesamtbewertung - AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung - FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy) - Evaluation.tsx ← Auswertung (teilweise veraltet, nutzt alte Criterion-Tabelle) + meeting/ + MeetingView.tsx ← Route-Komponente: State, DB-Handler, Header, Tabs + ConversationTab.tsx ← Gesprächsprotokoll-UI + AssessmentTab.tsx ← Gesamtbewertungs-UI (Accordion je Kategorie), Notation-Vorschlag + CriterionTagPicker.tsx ← Chip+Popover mit Suche, Kriterium-Zuordnung pro Protokoll-Zeile + AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung + Trend-Vorschlag + FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy, nutzt noch alte Criterion-Tabelle) + Evaluation.tsx ← Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, gruppiert nach Assignment Configuration.tsx ← Tab-Container für alle Konfigurationsseiten FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum) + RatingWeightConfig.tsx ← Config-Tab "Gewichtung": Zeitgewichtungs-Faktor für Bewertungs-Vorschlag Consultants.tsx ← Berater/Gruppen-Verwaltung utils/ meetingExport.ts ← Markdown + JSON Export-Logik dbBackup.ts ← Vollständiges DB-Backup (JSON Export/Import) - notationParser.tsx ← +/!/>/[…] Notation → farbiges JSX + notationParser.tsx ← +/!/>/[…] Notation → farbiges JSX, splitNoteLines(), detectNotationRating() + ratingTrend.ts ← Zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung App.tsx ← Route-Definitionen, Navigation main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf ``` @@ -48,10 +63,10 @@ src/ ## Datenmodell (IndexedDB via Dexie v5) -### DB-Version: 5 +### DB-Version: 6 ```typescript -// Konfiguration (Legacy, nur noch für Evaluation.tsx) +// Konfiguration (Legacy, nur noch für FeedbackDraftPage.tsx + Seeds) categories: '++id, order' criteria: '++id, categoryId, order' @@ -88,8 +103,13 @@ feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId' // Mappings (Legacy, kaum genutzt) criterionCategoryMappings: '++id, criterionId, feedbackCategoryId' criterionLevelDescriptions: '++id, criterionItemId, rating' + +// App-Einstellungen (Singleton, id immer 1), seit Version 6 +appSettings: '++id' ``` +`ConversationEntry.lineTags` (Kriterium-Zuordnung pro Zeile) ist ein reines Objektfeld ohne eigenen Index — kein Versions-Bump nötig, da Dexies `.stores()` nur Indizes definiert, nicht das volle Objekt-Schema. + ### Wichtige Interface-Details ```typescript @@ -135,12 +155,34 @@ interface FeedbackCriterionItem { id?: number categoryId: number name: string - weight?: number // Gewichtung ×1–×5, default 1 + weight?: number // 0,0–2,0 in 0,1-Schritten, default 1 (seit 2026-07-03; vorher grobe ×1–×5-Stufen) order: number } -// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung) -// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung) +interface ConversationLineTag { + text: string // exakter Zeilentext, 1:1 wie in ConversationEntry.note + criterionItemId: number // → FeedbackCriterionItem.id +} + +interface ConversationEntry { + id?: number + meetingInstanceId: number + instituteeId: number + sequenceIndex: number + note: string // ganzer Sprecher-Turn, mehrzeilig + fillerCount: number + updatedAt: string + lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per Zeilentext +} + +interface AppSettings { + id?: number // Singleton, immer 1 + ratingTrendWeightStep: number // Zeitgewichtungs-Faktor für den Bewertungs-Vorschlag +} + +// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung) +// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung) +// ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Zeilen-Zuordnung, bewusst anders benannt) ``` --- @@ -157,7 +199,7 @@ interface FeedbackCriterionItem { /assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy) /evaluation Auswertungsübersicht /consultants Berater-Verwaltung -/config Konfiguration (3 Tabs: Feedback | Ass.-Typen | Backup) +/config Konfiguration (4 Tabs: Feedback | Ass.-Typen | Gewichtung | Backup) ``` --- @@ -168,7 +210,7 @@ interface FeedbackCriterionItem { CRUD für: - Feedback-Dimensionen (mit Reihenfolge ↑/↓) - Feedback-Kategorien je Dimension -- Feedback-Kriterium-Items je Kategorie (mit Gewichtung ×1–×5) +- Feedback-Kriterium-Items je Kategorie (mit Gewichtung `0,0–2,0` in `0,1`-Schritten) ### Tab "Ass.-Typen" → `AssignmentTypeConfig` CRUD für: @@ -177,6 +219,9 @@ CRUD für: - Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State) - `criteriaIds = []` → alle Kriterien werden angezeigt +### Tab "Gewichtung" → `RatingWeightConfig` +- Ein Regler für `AppSettings.ratingTrendWeightStep` (Zeitgewichtungs-Faktor des Bewertungs-Vorschlags), mit Live-Vorschau der resultierenden Gewichte + ### Tab "Backup" → in `Configuration.tsx` - JSON-Export aller Tabellen - Zweistufiger JSON-Import mit Überschreib-Warnung @@ -197,6 +242,7 @@ function catMode(cat: FeedbackCategory, fbItems: FeedbackCriterionItem[], getSco .filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') if (scored.length === 0) return null const wSum = scored.reduce((s, x) => s + x.w, 0) + if (wSum === 0) return null // alle beteiligten Kriterien auf Gewicht 0 → sonst NaN < x überall false, fällt auf "Fully" durch const avg = scored.reduce((s, x) => s + NUM_MAP[x.score] * x.w, 0) / wSum if (avg < 1.5) return 'not_client_ready' if (avg < 2.5) return 'partially_client_ready' @@ -205,6 +251,8 @@ function catMode(cat: FeedbackCategory, fbItems: FeedbackCriterionItem[], getSco } ``` +Dieselbe `Σweight === 0`-Absicherung gilt für jede gewichtete Mittelung in der App (auch `buildGroupMeetingScores()` in `ratingTrend.ts`) — seit Gewicht `0` ein gültiger Wert ist, ist das kein theoretischer Randfall mehr. + --- ## Export-Dateiname-Schema @@ -227,8 +275,9 @@ const buildFilename = (ext: string) => { - **Provider:** OpenRouter (https://openrouter.ai) — ermöglicht Modellwechsel ohne eigenen API-Zugang - **Modelle:** GPT-4o mini, GPT-4o, Claude Sonnet 4.5, Claude Haiku 4.5, Gemini Flash 1.5 - **API-Key:** Lokal in `localStorage` gespeichert (`ai_api_key`, `ai_model`) -- **Datenweitergabe:** Kriterien-Scores, Protokoll-Notizen, Kategorie-Bewertungen werden im Prompt strukturiert übermittelt -- **Hinweis:** Der AI-Prompt nutzt noch die alte `criteria`-Tabelle (Legacy) — Aktualisierung auf `feedbackCriterionItems` steht aus +- **Datenweitergabe:** Kriterien-Scores + Notizen (Assessment und getaggte Protokoll-Zeilen zusammengeführt pro Kriterium), Kategorie-Bewertungen, zeitgewichteter Trend pro Kategorie werden im Prompt strukturiert übermittelt (`buildAiPrompt`, nutzt korrekt `feedbackCriterionItems`, alter `db.criteria`-Bug ist gefixt) +- **Bekanntes Problem:** Der Call schlägt aktuell fehl (vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`), Root Cause nicht untersucht +- **Geplanter Umbau (nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Platzhaltersystem soll den aktuellen Einzel-Prompt ablösen --- @@ -237,7 +286,7 @@ const buildFilename = (ext: string) => { Die App legt beim ersten Start automatisch an: 1. **3 Assignment-Typen** mit je vollständigem Phasen-Flow (Generisch, Case Interview, Stakeholder Meeting / Pitch) -2. **Legacy-Kriterien** in `categories` + `criteria` (für Evaluation.tsx) +2. **Legacy-Kriterien** in `categories` + `criteria` (nur noch von `FeedbackDraftPage.tsx` genutzt, `Evaluation.tsx` ist längst auf `feedbackCriterionItems` migriert) 3. **Feedback-Dimensionsstruktur** mit 5 Dimensionen, 14 Kategorien, ~70 Kriterium-Items (Capgemini-Kompetenzmodell) 4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt) @@ -247,10 +296,13 @@ Die App legt beim ersten Start automatisch an: | Bereich | Problem | Priorität | |---|---|---| -| `Evaluation.tsx` | Nutzt alte `Category`/`Criterion`-Tabellen statt neue `feedbackCriterionItems` | Mittel | -| `meetingExport.ts` | Bewertungs-Labels im Markdown-Export zeigen `FeedbackRating`-Keys statt Labels | Niedrig | +| `FeedbackDraftPage.tsx` | Nutzt alte `Category`/`Criterion`-Tabellen statt neue `feedbackCriterionItems`; letzter Konsument der Legacy-Tabellen außer den Seeds | Mittel | +| `meetingExport.ts` | Bewertungs-Labels im Markdown-Export zeigen `FeedbackRating`-Keys (z.B. `client_ready`) statt sprechender Labels | Niedrig | | `FeedbackStructureConfig.tsx` | Enthält toten Code (Funktionen für gelöschte AssignmentType-CRUD) | Niedrig | -| AI-Prompt in `AssignmentFeedbackPage` | Nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` nutzen | Mittel | +| AI-Prompt & `FeedbackDraftPage` | N/A-Aggregationsbug: `score !== null` schließt `'na'` nicht aus, verfälscht Mittelwerte | Hoch | +| KI-Generierung | `generateWithAi()` schlägt fehl (vermutlich Netzwerk/Firewall), Root Cause offen | Hoch | +| Prompt-System | Aktueller Einzel-Prompt soll mehrstufig/pro Dimension konfigurierbar werden — eigene Design-Session nötig | Hoch (nicht gescoped) | +| Selbstlernendes Tagging | Idee, automatische Tag-Vorschläge aus bisherigen Zuordnungen abzuleiten — eigene Design-Session nötig | Mittel (nicht gescoped) | | Dashboard | Zeigt keine Assignment-Nummer | Niedrig | -| `AssignmentCreate` | Kein Feld für Assignment-Nummer | Niedrig | | IndexedDB | Keine robuste Persistenz-Garantie (Browser kann löschen) | Hoch → SQLite WASM geplant | +| `db/queries/`-Layer | Fehlt — DB-Zugriffe direkt aus Komponenten, erschwert SQLite-Migration | Mittel | diff --git a/src/config/constants.ts b/src/config/constants.ts index 59d44f5..ac97f0f 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -44,3 +44,9 @@ export const RATING_NUM_MAP: Record = { nearly_client_ready: 3, client_ready: 4, } + +/** Default-Zeitgewichtung für den Bewertungs-Vorschlag, überschreibbar über Config > Gewichtung. */ +export const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5 + +/** Mindest-Punktedelta zwischen erstem und letztem bewerteten Meeting einer Kategorie, ab dem ein Trend gilt. */ +export const RATING_TREND_DELTA_THRESHOLD = 0.5 diff --git a/src/db/schema.ts b/src/db/schema.ts index 50a81ec..d350d6e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -9,6 +9,7 @@ import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, CriterionCategoryMapping, CriterionLevelDescription, + AppSettings, } from './types' class AssignmentMonitorDB extends Dexie { @@ -32,6 +33,7 @@ class AssignmentMonitorDB extends Dexie { feedbackDimensionTexts!: Table criterionCategoryMappings!: Table criterionLevelDescriptions!: Table + appSettings!: Table constructor() { super('AssignmentMonitorDB') @@ -129,6 +131,31 @@ class AssignmentMonitorDB extends Dexie { meetings: null, consultantNotes: null, }) + this.version(6).stores({ + categories: '++id, order', + criteria: '++id, categoryId, order', + assignmentTypes: '++id', + phaseTemplates: '++id, assignmentTypeId, order', + groups: '++id', + consultants: '++id, groupId', + assignments: '++id, status, createdAt', + meetingInstances: '++id, assignmentId, phaseTemplateId', + conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex', + conversationSkillScores: '++id, conversationEntryId, criteriaId', + assessments: '++id, meetingInstanceId, instituteeId, criteriaId', + feedbackDrafts: '++id, assignmentId, instituteeId', + feedbackDimensions: '++id, order', + feedbackCategories: '++id, dimensionId, order', + feedbackCriterionItems: '++id, categoryId, order', + assignmentFeedbacks: '++id, assignmentId, instituteeId', + feedbackCategoryRatings: '++id, assignmentFeedbackId, feedbackCategoryId', + feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId', + criterionCategoryMappings: '++id, criterionId, feedbackCategoryId', + criterionLevelDescriptions: '++id, criterionItemId, rating', + appSettings: '++id', + meetings: null, + consultantNotes: null, + }) } } diff --git a/src/db/types.ts b/src/db/types.ts index 3049c6e..5986120 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -98,6 +98,11 @@ export interface MeetingInstance { // ─── Gesprächs-Zeitleiste ───────────────────────────────────────────────────── +export interface ConversationLineTag { + text: string + criterionItemId: number // → FeedbackCriterionItem.id +} + export interface ConversationEntry { id?: number meetingInstanceId: number @@ -106,6 +111,7 @@ export interface ConversationEntry { note: string fillerCount: number updatedAt: string + lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per exaktem Zeilentext } // criteriaId → FeedbackCategory.id (Schnellbewertung während Gesprächsbeitrag) @@ -200,3 +206,10 @@ export interface CriterionLevelDescription { rating: FeedbackRating text: string } + +// ─── App-Einstellungen (Singleton, id immer 1) ─────────────────────────────── + +export interface AppSettings { + id?: number + ratingTrendWeightStep: number // 0 = keine Zeitgewichtung, höher = stärkere Gewichtung späterer Meetings +} diff --git a/src/pages/AssignmentCreate.tsx b/src/pages/AssignmentCreate.tsx index 9ac807c..206f7c1 100644 --- a/src/pages/AssignmentCreate.tsx +++ b/src/pages/AssignmentCreate.tsx @@ -12,6 +12,7 @@ export default function AssignmentCreate() { title: '', client: '', leadName: '', + assignmentNumber: '', assignmentTypeId: 0, selectedGroupId: 0, instituteeIds: [] as number[], @@ -66,11 +67,12 @@ export default function AssignmentCreate() { } const create = async () => { - if (!form.title.trim() || !form.client.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return + if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return const id = await db.assignments.add({ title: form.title, client: form.client, leadName: form.leadName, + assignmentNumber: form.assignmentNumber.trim(), assignmentTypeId: form.assignmentTypeId, instituteeIds: form.instituteeIds, status: 'active', @@ -79,7 +81,7 @@ export default function AssignmentCreate() { navigate(`/assignment/${id}`) } - const canCreate = form.title.trim() && form.client.trim() && form.assignmentTypeId && form.instituteeIds.length > 0 + const canCreate = form.title.trim() && form.client.trim() && form.assignmentNumber.trim() && form.assignmentTypeId && form.instituteeIds.length > 0 const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!)) return ( @@ -88,6 +90,9 @@ export default function AssignmentCreate() { {/* Basisdaten */}
+ setForm(p => ({ ...p, assignmentNumber: e.target.value }))} /> setForm(p => ({ ...p, title: e.target.value }))} /> diff --git a/src/pages/AssignmentFeedbackPage.tsx b/src/pages/AssignmentFeedbackPage.tsx index a7908ee..773d4ea 100644 --- a/src/pages/AssignmentFeedbackPage.tsx +++ b/src/pages/AssignmentFeedbackPage.tsx @@ -12,7 +12,9 @@ import { type FeedbackDimensionText, type FeedbackRating, } from '../db' -import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG } from '../config/constants' +import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' +import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend' +import { splitNoteLines } from '../utils/notationParser' const ratingToNum = (r: FeedbackRating | null | undefined): number => r ? (RATING_NUM_MAP[r] ?? 0) : 0 @@ -20,6 +22,12 @@ const ratingToNum = (r: FeedbackRating | null | undefined): number => const ratingColor = (r: FeedbackRating | undefined) => RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200' +const trendArrow = (trend: TrendResult['trend']) => { + if (trend === 'up') return + if (trend === 'down') return + return null +} + function getAiSettings() { return { apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '', @@ -39,9 +47,9 @@ async function buildAiPrompt( assignment: Assignment, dimensions: FeedbackDimension[], categories: FeedbackCategory[], - _items: FeedbackCriterionItem[], + items: FeedbackCriterionItem[], ratings: FeedbackCategoryRating[], - _feedbackId: number, + catTrends: Map, ): Promise { const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray() const meetingIds = meetings.map(m => m.id!) @@ -54,24 +62,33 @@ async function buildAiPrompt( const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id) const instEntries = allEntries.filter(e => e.instituteeId === institutee.id) - const critIds = [...new Set(instAssessments.map(a => a.criteriaId))] - const crits = critIds.length > 0 - ? await db.criteria.where('id').anyOf(critIds).toArray() - : [] + const allLineTags = instEntries.flatMap(e => e.lineTags ?? []) + + const critIds = [...new Set([ + ...instAssessments.map(a => a.criteriaId), + ...allLineTags.map(t => t.criterionItemId), + ])] + const crits = items.filter(i => critIds.includes(i.id!)) const scoreLines = crits.map(c => { const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null) const avg = scores.length > 0 ? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1) : '—' - const notes = instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note) + const notes = [ + ...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note), + ...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text), + ] return ` ${c.name}: ${avg}/5${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}` }).join('\n') const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0) const protocolLines = instEntries - .filter(e => e.note) - .map(e => ` - ${e.note}${(e.fillerCount ?? 0) > 0 ? ` [äh×${e.fillerCount}]` : ''}`) + .flatMap(e => splitNoteLines(e.note).map(line => ({ + line, tagged: (e.lineTags ?? []).some(t => t.text === line), + }))) + .filter(x => !x.tagged) + .map(x => ` - ${x.line}`) .join('\n') // Kategorie-Ratings @@ -85,6 +102,22 @@ async function buildAiPrompt( return ` Dimension "${dim.name}":\n${lines}` }).join('\n') + // Entwicklung über die Laufzeit (zeitgewichteter Trend pro Kategorie) + const trendLines = dimensions.map(dim => { + const dimCats = categories.filter(c => c.dimensionId === dim.id) + const lines = dimCats.map(cat => { + const t = catTrends.get(cat.id!) + if (!t || t.history.length === 0) return ` ${cat.name}: keine Verlaufsdaten` + const seq = t.history.map(h => h.score.toFixed(1)).join(' → ') + const trendLabel = t.trend === 'up' ? 'Verbesserung über die Laufzeit' + : t.trend === 'down' ? 'Verschlechterung über die Laufzeit' + : t.trend === 'stable' ? 'stabil' + : 'nur ein Meeting, kein Trend ableitbar' + return ` ${cat.name}: Verlauf ${seq} (${trendLabel})` + }).join('\n') + return ` Dimension "${dim.name}":\n${lines}` + }).join('\n') + // Feedback-Struktur für Output const dimStructure = dimensions.map((dim, i) => `${i + 1}. ${dim.name}` @@ -100,13 +133,16 @@ FÜLLWÖRTER GESAMT: ${fillerTotal} BEOBACHTETE KRITERIEN UND SCORES (1-5): ${scoreLines || '(keine Scores erfasst)'} -PROTOKOLL-NOTIZEN (Kurznotizen aus dem Gespräch, Notation: + gut, ! negativ, > Kundeninput): -${protocolLines || '(keine Protokolleinträge)'} +PROTOKOLL-NOTIZEN, KEINEM KRITERIUM ZUGEORDNET (Notation: + gut, ! negativ, > Kundeninput): +${protocolLines || '(keine nicht zugeordneten Protokolleinträge)'} KATEGORIE-BEWERTUNGEN: ${ratingLines} -Generiere für jede der folgenden ${dimensions.length} Dimensionen je einen kurzen Absatz "Achievements" (2-4 Sätze, konkret, anerkennend) und "Development Needs" (2-4 Sätze, konstruktiv, handlungsorientiert). Falls eine Dimension aus den Daten nicht beurteilbar ist, schreibe "n/a". +ENTWICKLUNG ÜBER DIE LAUFZEIT (chronologische Kategorie-Scores aus den Meetings, Skala 0-4): +${trendLines} + +Generiere für jede der folgenden ${dimensions.length} Dimensionen je einen kurzen Absatz "Achievements" (2-4 Sätze, konkret, anerkennend) und "Development Needs" (2-4 Sätze, konstruktiv, handlungsorientiert). Falls eine Dimension aus den Daten nicht beurteilbar ist, schreibe "n/a". Erwähne eine erkennbare Verbesserung im Achievements-Absatz und eine erkennbare Verschlechterung im Development-Needs-Absatz der jeweiligen Dimension. Ausgabe als JSON: { @@ -142,6 +178,7 @@ export default function AssignmentFeedbackPage() { const [ratings, setRatings] = useState([]) const [dimTexts, setDimTexts] = useState([]) const [expanded, setExpanded] = useState>(new Set()) + const [catTrends, setCatTrends] = useState>(new Map()) const [showAiSettings, setShowAiSettings] = useState(false) const [aiKey, setAiKey] = useState(getAiSettings().apiKey) const [aiModel, setAiModel] = useState(getAiSettings().model) @@ -163,6 +200,27 @@ export default function AssignmentFeedbackPage() { setCategories(cats) setItems(itms) + const meetings = (await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray()) + .filter(m => !m.deletedAt && m.status === 'done') + .sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime()) + const meetingIds = meetings.map(m => m.id!) + const [meetingAssessments, settings] = await Promise.all([ + meetingIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray() : Promise.resolve([]), + db.appSettings.get(1), + ]) + const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP + const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId) + const getScore = (meetingId: number, itemId: number): FeedbackRating | null => + instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null + + const trends = new Map() + for (const cat of cats) { + const catItems = itms.filter(i => i.categoryId === cat.id) + const history = buildGroupMeetingScores(catItems, meetings, getScore) + trends.set(cat.id!, computeTrend(history, weightStep)) + } + setCatTrends(trends) + let fb = await db.assignmentFeedbacks .where('assignmentId').equals(assignmentId) .filter(f => f.instituteeId === instId) @@ -243,6 +301,17 @@ export default function AssignmentFeedbackPage() { const getDimText = (dimId: number) => dimTexts.find(t => t.feedbackDimensionId === dimId) + const appendTrendSentence = (dimId: number, cat: FeedbackCategory, trend: TrendResult) => { + if (trend.trend !== 'up' && trend.trend !== 'down') return + const field = trend.trend === 'up' ? 'achievements' : 'developmentNeeds' + const firstLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[0].score))?.short + const lastLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[trend.history.length - 1].score))?.short + const verb = trend.trend === 'up' ? 'Verbesserung' : 'Verschlechterung' + const sentence = `Zeigt im Verlauf des Assignments in ${cat.name} eine ${verb} von ${firstLabel} zu ${lastLabel}.` + const existing = getDimText(dimId)?.[field] ?? '' + saveDimText(dimId, field, existing ? `${existing}\n${sentence}` : sentence) + } + // ── KI-Generierung ──────────────────────────────────────────────────────── const generateWithAi = async () => { @@ -253,7 +322,7 @@ export default function AssignmentFeedbackPage() { setGenerating(true) setAiError('') try { - const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, feedback.id) + const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, catTrends) const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', @@ -462,6 +531,33 @@ export default function AssignmentFeedbackPage() { ))}
+ {/* Bewertungs-Vorschlag aus Meeting-Assessments */} + {(() => { + const trend = catTrends.get(cat.id!) + if (!trend || trend.suggestion === null) return null + const suggestedRating = numberToRating(trend.suggestion) + const shortLabel = RATING_OPTIONS.find(o => o.value === suggestedRating)?.short + return ( +
+ + Vorschlag: {shortLabel} ({trend.suggestion.toFixed(1)}) {trendArrow(trend.trend)} + + + {(trend.trend === 'up' || trend.trend === 'down') && ( + + )} +
+ ) + })()} + {/* Kriterium-Items als Referenz */} {catItems.length > 0 && (
diff --git a/src/pages/Configuration.tsx b/src/pages/Configuration.tsx index 363b35e..57f6cba 100644 --- a/src/pages/Configuration.tsx +++ b/src/pages/Configuration.tsx @@ -1,10 +1,11 @@ import { useRef, useState } from 'react' import FeedbackStructureConfig from './FeedbackStructureConfig' import AssignmentTypeConfig from './AssignmentTypeConfig' +import RatingWeightConfig from './RatingWeightConfig' import { exportFullBackup, importFullBackup } from '../utils/dbBackup' export default function Configuration() { - const [tab, setTab] = useState<'feedback' | 'types' | 'backup'>('feedback') + const [tab, setTab] = useState<'feedback' | 'types' | 'weighting' | 'backup'>('feedback') const [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null) const [restoreConfirm, setRestoreConfirm] = useState(false) const [importing, setImporting] = useState(false) @@ -48,6 +49,10 @@ export default function Configuration() { className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'types' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}> Ass.-Typen +
)} - {tab === 'feedback' && } - {tab === 'types' && } + {tab === 'feedback' && } + {tab === 'types' && } + {tab === 'weighting' && } {tab === 'backup' && (
diff --git a/src/pages/Evaluation.tsx b/src/pages/Evaluation.tsx index 40daa42..9700963 100644 --- a/src/pages/Evaluation.tsx +++ b/src/pages/Evaluation.tsx @@ -3,33 +3,24 @@ import { useNavigate } from 'react-router-dom' import { db, type Assignment, type Consultant, - type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, + type FeedbackDimension, type FeedbackCategory, type FeedbackRating, } from '../db' -import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants' +import { RATING_OPTIONS, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' +import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend' interface CatScore { - category: FeedbackCategory - avg: number | null + category: FeedbackCategory + avg: number | null + trend: TrendResult['trend'] } interface InstScore { - institutee: Consultant - assignment: Assignment - catScores: CatScore[] - overall: number | null -} - -function weightedAvg( - items: FeedbackCriterionItem[], - getScore: (itemId: number) => FeedbackRating | null, -): number | null { - const rated = items - .map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 })) - .filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') - if (rated.length === 0) return null - const wSum = rated.reduce((s, x) => s + x.w, 0) - return rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum + institutee: Consultant + assignment: Assignment + catScores: CatScore[] + overall: number | null + overallTrend: TrendResult['trend'] } function ratingColor(avg: number | null): string { @@ -51,6 +42,12 @@ function ratingLabel(avg: number | null): string { return r ? `${r.short} (${avg.toFixed(1)})` : avg.toFixed(1) } +function trendArrow(trend: TrendResult['trend']) { + if (trend === 'up') return + if (trend === 'down') return + return null +} + export default function Evaluation() { const [data, setData] = useState([]) const [dimensions, setDimensions] = useState([]) @@ -60,22 +57,26 @@ export default function Evaluation() { useEffect(() => { const load = async () => { - const [assignments, consultants, dims, cats, items, assessments] = await Promise.all([ + const [assignments, consultants, dims, cats, items, assessments, settings] = await Promise.all([ db.assignments.toArray(), db.consultants.toArray(), db.feedbackDimensions.orderBy('order').toArray(), db.feedbackCategories.orderBy('order').toArray(), db.feedbackCriterionItems.orderBy('order').toArray(), db.assessments.toArray(), + db.appSettings.get(1), ]) setDimensions(dims) setCategories(cats) + const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP const rows: InstScore[] = [] for (const a of assignments) { - const meetingIds = (await db.meetingInstances + const meetings = (await db.meetingInstances .where('assignmentId').equals(a.id!).toArray() - ).filter(m => !m.deletedAt && m.status === 'done').map(m => m.id!) + ).filter(m => !m.deletedAt && m.status === 'done') + .sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime()) + const meetingIds = meetings.map(m => m.id!) for (const instId of a.instituteeIds) { const inst = consultants.find(c => c.id === instId) @@ -86,25 +87,20 @@ export default function Evaluation() { ) if (instAssessments.length === 0) continue - const getScore = (itemId: number): FeedbackRating | null => { - const relevant = instAssessments.filter(a => a.criteriaId === itemId && a.score !== null && a.score !== 'na') - if (relevant.length === 0) return null - const avg = relevant.reduce((s, a) => s + RATING_NUM_MAP[a.score!], 0) / relevant.length - if (avg < 1.5) return 'not_client_ready' - if (avg < 2.5) return 'partially_client_ready' - if (avg < 3.5) return 'nearly_client_ready' - return 'client_ready' - } + const getScore = (meetingId: number, itemId: number): FeedbackRating | null => + instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null - const catScores: CatScore[] = cats.map(cat => ({ - category: cat, - avg: weightedAvg(items.filter(i => i.categoryId === cat.id), getScore), - })) + const catScores: CatScore[] = cats.map(cat => { + const history = buildGroupMeetingScores(items.filter(i => i.categoryId === cat.id), meetings, getScore) + const { suggestion, trend } = computeTrend(history, weightStep) + return { category: cat, avg: suggestion, trend } + }) - const overall = weightedAvg(items, getScore) + const overallHistory = buildGroupMeetingScores(items, meetings, getScore) + const { suggestion: overall, trend: overallTrend } = computeTrend(overallHistory, weightStep) if (overall === null && catScores.every(c => c.avg === null)) continue - rows.push({ institutee: inst, assignment: a, catScores, overall }) + rows.push({ institutee: inst, assignment: a, catScores, overall, overallTrend }) } } setData(rows) @@ -150,8 +146,8 @@ export default function Evaluation() { {row.institutee.firstName} {row.institutee.lastName}
-
- {ratingLabel(row.overall)} +
+ {ratingLabel(row.overall)} {trendArrow(row.overallTrend)}
diff --git a/src/pages/RatingWeightConfig.tsx b/src/pages/RatingWeightConfig.tsx new file mode 100644 index 0000000..d389390 --- /dev/null +++ b/src/pages/RatingWeightConfig.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react' +import { db } from '../db' +import { DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' +import { previewWeights } from '../utils/ratingTrend' + +export default function RatingWeightConfig() { + const [weightStep, setWeightStep] = useState(DEFAULT_RATING_TREND_WEIGHT_STEP) + const [saved, setSaved] = useState(false) + + useEffect(() => { + const load = async () => { + let settings = await db.appSettings.get(1) + if (!settings) { + await db.appSettings.add({ id: 1, ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP }) + settings = await db.appSettings.get(1) + } + setWeightStep(settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP) + } + load() + }, []) + + const save = async () => { + await db.appSettings.put({ id: 1, ratingTrendWeightStep: weightStep }) + setSaved(true) + setTimeout(() => setSaved(false), 2500) + } + + return ( +
+
+

Zeitgewichtung des Bewertungs-Vorschlags

+

+ Steuert, wie stark spätere Meetings in den Bewertungs-Vorschlag auf der Auswertungs- und + Feedback-Seite einfließen. 0 = alle Meetings gleich gewichtet, höher = stärkere Gewichtung + späterer Bewertungen. +

+
+ +
+
+ + {weightStep.toFixed(1)} +
+ setWeightStep(Number(e.target.value))} + className="w-full" + /> +
+ +
+
+ Beispiel bei 3 bewerteten Meetings +
+
+ {previewWeights(3, weightStep).map((w, i) => ( + + Meeting {i + 1}: ×{w.toFixed(1)} + + ))} +
+
+ + + {saved && ( +
+ Gespeichert. +
+ )} +
+ ) +} diff --git a/src/pages/meeting/AssessmentTab.tsx b/src/pages/meeting/AssessmentTab.tsx index 5346c6f..a199e8b 100644 --- a/src/pages/meeting/AssessmentTab.tsx +++ b/src/pages/meeting/AssessmentTab.tsx @@ -1,15 +1,18 @@ import { useState } from 'react' import type { Consultant, FeedbackCategory, FeedbackCriterionItem, - Assessment, FeedbackRating, + Assessment, ConversationEntry, FeedbackRating, } from '../../db' import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants' +import { detectNotationRating } from '../../utils/notationParser' +import { numberToRating } from '../../utils/ratingTrend' interface Props { institutees: Consultant[] fbCategories: FeedbackCategory[] fbItems: FeedbackCriterionItem[] assessments: Assessment[] + entries: ConversationEntry[] hasConv: boolean tagLabel: (inst: Consultant) => string fillerTotal: (instId: number) => number @@ -30,6 +33,7 @@ function catMode( .filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') if (scored.length === 0) return null const wSum = scored.reduce((s, x) => s + x.w, 0) + if (wSum === 0) return null // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch const avg = scored.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum if (avg < 1.5) return 'not_client_ready' if (avg < 2.5) return 'partially_client_ready' @@ -37,8 +41,20 @@ function catMode( return 'client_ready' } +function suggestedRating(entries: ConversationEntry[], instituteeId: number, itemId: number): FeedbackRating | null { + const ratings = entries + .filter(e => e.instituteeId === instituteeId) + .flatMap(e => e.lineTags ?? []) + .filter(t => t.criterionItemId === itemId) + .map(t => detectNotationRating(t.text)) + .filter((r): r is FeedbackRating => r !== null) + if (ratings.length === 0) return null + const avg = ratings.reduce((s, r) => s + RATING_NUM_MAP[r], 0) / ratings.length + return numberToRating(avg) +} + export function AssessmentTab({ - institutees, fbCategories, fbItems, assessments, hasConv, + institutees, fbCategories, fbItems, assessments, entries, hasConv, tagLabel, fillerTotal, getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc, }: Props) { @@ -82,6 +98,11 @@ export function AssessmentTab({ const isOpen = expandedCat[inst.id!] === cat.id const mode = catMode(cat, fbItems, itemId => getAssessmentScore(inst.id!, itemId)) const headerRo = mode ? RATING_OPTIONS.find(r => r.value === mode) : null + const suggestionCount = catItems.filter(item => { + const current = getAssessmentScore(inst.id!, item.id!) + const suggestion = suggestedRating(entries, inst.id!, item.id!) + return suggestion && suggestion !== current + }).length return (
@@ -95,6 +116,14 @@ export function AssessmentTab({ }`}> {cat.name}
+ {suggestionCount > 0 && ( + + 💡 {suggestionCount} + + )} {!headerRo && ratedCount > 0 && ( {ratedCount}/{catItems.length} )} @@ -105,7 +134,8 @@ export function AssessmentTab({ {isOpen && (
{catItems.map(item => { - const current = getAssessmentScore(inst.id!, item.id!) + const current = getAssessmentScore(inst.id!, item.id!) + const suggestion = suggestedRating(entries, inst.id!, item.id!) return (
{item.name}
@@ -120,6 +150,17 @@ export function AssessmentTab({ ))}
+ {suggestion && suggestion !== current && ( +
+ + Vorschlag aus Protokoll: {RATING_OPTIONS.find(r => r.value === suggestion)?.short} + + +
+ )}
) })} diff --git a/src/pages/meeting/ConversationTab.tsx b/src/pages/meeting/ConversationTab.tsx index 3f98c89..02e4cc5 100644 --- a/src/pages/meeting/ConversationTab.tsx +++ b/src/pages/meeting/ConversationTab.tsx @@ -5,6 +5,7 @@ import type { } from '../../db' import { NotationText } from '../../utils/notationParser' import { INST_COLORS, RATING_OPTIONS } from '../../config/constants' +import { CriterionTagPicker } from './CriterionTagPicker' const NOTATIONS = [ { sym: '+', tip: 'Gut' }, @@ -37,6 +38,8 @@ interface Props { onSaveEntryNote: () => void onIncrementFiller: () => void onSetEntryScore: (entryId: number, categoryId: number, score: FeedbackRating) => void + onSetLineCriterion: (entryId: number, lineText: string, criterionItemId: number | null) => void + onChangeSpeaker: (entryId: number, instituteeId: number) => void onStartEdit: (entry: ConversationEntry) => void onSaveEdit: (entryId: number) => void onCancelEdit: () => void @@ -50,7 +53,7 @@ export function ConversationTab({ activeInstId, activeEntryId, entryNote, editingId, editNote, noteRef, displayName, tagLabel, fillerTotal, getEntryScore, onActivateInstitutee, onCloseActiveEntry, onInsertNotation, - onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore, + onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore, onSetLineCriterion, onChangeSpeaker, onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller, }: Props) { const activeInstIdx = institutees.findIndex(c => c.id === activeInstId) @@ -206,6 +209,17 @@ export function ConversationTab({ onChange={e => onEditNoteChange(e.target.value)} autoFocus /> +
+ Sprecher: + +
Füllwörter:
) : ( <> - {entry.note && } -
+ {entry.note && ( + ( + t.text === line)?.criterionItemId ?? null} + onChange={itemId => onSetLineCriterion(entry.id!, line, itemId)} + /> + )} + /> + )} +
{(entry.fillerCount ?? 0) > 0 && ( äh×{entry.fillerCount} diff --git a/src/pages/meeting/CriterionTagPicker.tsx b/src/pages/meeting/CriterionTagPicker.tsx new file mode 100644 index 0000000..b0d5ff3 --- /dev/null +++ b/src/pages/meeting/CriterionTagPicker.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import type { FeedbackCategory, FeedbackCriterionItem } from '../../db' + +interface Props { + items: FeedbackCriterionItem[] + categories: FeedbackCategory[] + value: number | null | undefined + onChange: (itemId: number | null) => void +} + +export function CriterionTagPicker({ items, categories, value, onChange }: Props) { + const [open, setOpen] = useState(false) + const [filter, setFilter] = useState('') + + const current = items.find(i => i.id === value) + const categoryName = (categoryId: number) => categories.find(c => c.id === categoryId)?.name ?? '' + + const filtered = items.filter(i => { + const q = filter.trim().toLowerCase() + if (!q) return true + return i.name.toLowerCase().includes(q) || categoryName(i.categoryId).toLowerCase().includes(q) + }) + + const select = (itemId: number) => { + onChange(itemId) + setOpen(false) + setFilter('') + } + + return ( +
+ {current ? ( +
+ + +
+ ) : ( + + )} + + {open && ( +
+ setFilter(e.target.value)} + className="w-full text-xs border border-gray-200 rounded-lg px-2 py-1.5 mb-2 focus:outline-none focus:border-brand" + /> +
+ {filtered.length === 0 && ( +
Kein Treffer.
+ )} + {filtered.map(item => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/src/pages/meeting/MeetingView.tsx b/src/pages/meeting/MeetingView.tsx index af92c9f..15dc917 100644 --- a/src/pages/meeting/MeetingView.tsx +++ b/src/pages/meeting/MeetingView.tsx @@ -7,7 +7,7 @@ import { type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment, } from '../../db' import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport' -import { NotationText } from '../../utils/notationParser' +import { NotationText, splitNoteLines } from '../../utils/notationParser' import { RATING_NUM_MAP } from '../../config/constants' import { ConversationTab } from './ConversationTab' import { AssessmentTab } from './AssessmentTab' @@ -96,7 +96,11 @@ export default function MeetingView() { const activateInstitutee = async (instId: number) => { if (activeEntryId !== null) { - await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) + if (entryNote.trim() === '') { + await db.conversationEntries.delete(activeEntryId) + } else { + await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) + } } const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0) const eid = await db.conversationEntries.add({ @@ -135,7 +139,11 @@ export default function MeetingView() { } const closeActiveEntry = async () => { - await saveEntryNote() + if (activeEntryId !== null && entryNote.trim() === '') { + await db.conversationEntries.delete(activeEntryId) + } else { + await saveEntryNote() + } setActiveInstId(null) setActiveEntryId(null) setEntryNote('') @@ -145,11 +153,24 @@ export default function MeetingView() { const startEdit = (entry: ConversationEntry) => { setEditingId(entry.id!); setEditNote(entry.note) } const saveEdit = async (entryId: number) => { - await db.conversationEntries.update(entryId, { note: editNote, updatedAt: new Date().toISOString() }) - setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote } : e)) + const entry = entries.find(e => e.id === entryId) + const currentLines = splitNoteLines(editNote) + const prunedTags = (entry?.lineTags ?? []).filter(t => currentLines.includes(t.text)) + await db.conversationEntries.update(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() }) + setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote, lineTags: prunedTags } : e)) setEditingId(null) } + const setLineCriterion = async (entryId: number, lineText: string, criterionItemId: number | null) => { + const entry = entries.find(e => e.id === entryId) + if (!entry) return + const currentLines = splitNoteLines(entry.note) + const kept = (entry.lineTags ?? []).filter(t => t.text !== lineText && currentLines.includes(t.text)) + const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }] + await db.conversationEntries.update(entryId, { lineTags: nextTags }) + setEntries(prev => prev.map(e => e.id === entryId ? { ...e, lineTags: nextTags } : e)) + } + const deleteEntry = async (entryId: number) => { await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete() await db.conversationEntries.delete(entryId) @@ -162,6 +183,11 @@ export default function MeetingView() { setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e)) } + const changeEntrySpeaker = async (entryId: number, instituteeId: number) => { + await db.conversationEntries.update(entryId, { instituteeId }) + setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e)) + } + const getEntryScore = (entryId: number, categoryId: number): FeedbackRating | null => scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null @@ -393,6 +419,8 @@ export default function MeetingView() { onSaveEntryNote={saveEntryNote} onIncrementFiller={incrementFiller} onSetEntryScore={setEntryScore} + onSetLineCriterion={setLineCriterion} + onChangeSpeaker={changeEntrySpeaker} onStartEdit={startEdit} onSaveEdit={saveEdit} onCancelEdit={() => setEditingId(null)} @@ -408,6 +436,7 @@ export default function MeetingView() { fbCategories={fbCategories} fbItems={fbItems} assessments={assessments} + entries={entries} hasConv={hasConv} tagLabel={tagLabel} fillerTotal={fillerTotal} diff --git a/src/utils/dbBackup.ts b/src/utils/dbBackup.ts index 719b7b9..f2c6c71 100644 --- a/src/utils/dbBackup.ts +++ b/src/utils/dbBackup.ts @@ -1,6 +1,8 @@ import { db } from '../db' import { downloadFile } from './meetingExport' +// Muss mit den Tabellen in db/schema.ts synchron gehalten werden — eine neue Tabelle dort +// landet nicht automatisch im Backup, sondern muss hier manuell ergänzt werden. const TABLE_NAMES = [ 'categories', 'criteria', @@ -22,6 +24,7 @@ const TABLE_NAMES = [ 'feedbackDimensionTexts', 'criterionCategoryMappings', 'criterionLevelDescriptions', + 'appSettings', ] as const export interface DbBackup { diff --git a/src/utils/meetingExport.ts b/src/utils/meetingExport.ts index d2015f9..18b530c 100644 --- a/src/utils/meetingExport.ts +++ b/src/utils/meetingExport.ts @@ -23,10 +23,11 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise a.criteriaId), + ...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)), ]), ] const criteria = critIds.length > 0 - ? await db.criteria.where('id').anyOf(critIds).toArray() + ? await db.feedbackCriterionItems.where('id').anyOf(critIds).toArray() : [] const institutees = instituteeIds @@ -63,7 +64,9 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise ${noteLine}`) + const lineTag = entry.lineTags?.find(t => t.text === noteLine.trim()) + const critName = lineTag ? criteria.find(c => c.id === lineTag.criterionItemId)?.name : null + lines.push(`> ${noteLine}${critName ? ` _(→ ${critName})_` : ''}`) } } diff --git a/src/utils/notationParser.tsx b/src/utils/notationParser.tsx index 858c1a3..0cab838 100644 --- a/src/utils/notationParser.tsx +++ b/src/utils/notationParser.tsx @@ -1,4 +1,22 @@ import React from 'react' +import type { FeedbackRating } from '../db' + +// Notation → Bewertungsstufe (Reihenfolge wichtig: Klammer-Varianten vor den nackten Zeichen prüfen, +// sonst matcht "+" bereits innerhalb von "(+)") +const NOTATION_RATING_ORDER: [string, FeedbackRating][] = [ + ['(+)', 'nearly_client_ready'], + ['(!)', 'partially_client_ready'], + ['+', 'client_ready'], + ['!', 'not_client_ready'], +] + +export function detectNotationRating(line: string): FeedbackRating | null { + const trimmed = line.trim() + for (const [prefix, rating] of NOTATION_RATING_ORDER) { + if (trimmed.startsWith(prefix)) return rating + } + return null +} // Notations-Regeln (Reihenfolge wichtig: längere Symbole zuerst) const NOTATION_RULES = [ @@ -57,14 +75,27 @@ function parseLine(line: string, key: number): React.ReactElement { interface Props { text: string className?: string + renderLineAddon?: (line: string, index: number) => React.ReactNode } -export function NotationText({ text, className }: Props) { +export function NotationText({ text, className, renderLineAddon }: Props) { if (!text?.trim()) return null const lines = text.split('\n') return (
- {lines.map((line, i) => parseLine(line, i))} + {lines.map((line, i) => renderLineAddon + ? ( +
+
{parseLine(line, i)}
+ {line.trim() && renderLineAddon(line.trim(), i)} +
+ ) + : parseLine(line, i) + )}
) } + +export function splitNoteLines(note: string): string[] { + return note.split('\n').map(l => l.trim()).filter(l => l !== '') +} diff --git a/src/utils/ratingTrend.ts b/src/utils/ratingTrend.ts new file mode 100644 index 0000000..0eec4b1 --- /dev/null +++ b/src/utils/ratingTrend.ts @@ -0,0 +1,73 @@ +import type { FeedbackCriterionItem, FeedbackRating } from '../db' +import { RATING_NUM_MAP, RATING_TREND_DELTA_THRESHOLD } from '../config/constants' + +export interface MeetingScorePoint { + meetingId: number + date: string + score: number +} + +export interface TrendResult { + suggestion: number | null + history: MeetingScorePoint[] + trend: 'up' | 'down' | 'stable' | null + delta: number | null +} + +/** + * Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall"). + * Meetings ohne bewertetes Item aus der Gruppe werden übersprungen — ein Delivery-Call, der eine + * Kategorie nicht abfragt, darf deren Zeitreihe nicht verfälschen. + */ +export function buildGroupMeetingScores( + items: FeedbackCriterionItem[], + meetingsChronological: { id: number; date: string }[], + getScore: (meetingId: number, itemId: number) => FeedbackRating | null, +): MeetingScorePoint[] { + const points: MeetingScorePoint[] = [] + for (const meeting of meetingsChronological) { + const rated = items + .map(i => ({ score: getScore(meeting.id, i.id!), w: i.weight ?? 1 })) + .filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') + if (rated.length === 0) continue + const wSum = rated.reduce((s, x) => s + x.w, 0) + if (wSum === 0) continue // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch + const score = rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum + points.push({ meetingId: meeting.id, date: meeting.date, score }) + } + return points +} + +/** + * Lineare Zeitgewichtung: rank r (1-basiert, 1 = ältester Datenpunkt) → weight = 1 + (r-1) * weightStep. + * Trend wird aus dem Delta zwischen erstem und letztem Rohwert abgeleitet (nicht dem gewichteten Wert), + * da das ohnehin nur ab 2 Datenpunkten sinnvoll ist und der gewichtete Wert selbst schon Teil des + * Vorschlags ist. + */ +export function computeTrend(history: MeetingScorePoint[], weightStep: number): TrendResult { + if (history.length === 0) return { suggestion: null, history, trend: null, delta: null } + + const weights = previewWeights(history.length, weightStep) + const wSum = weights.reduce((s, w) => s + w, 0) + const suggestion = history.reduce((s, p, i) => s + p.score * weights[i], 0) / wSum + + if (history.length < 2) return { suggestion, history, trend: null, delta: null } + + const delta = history[history.length - 1].score - history[0].score + const trend = delta >= RATING_TREND_DELTA_THRESHOLD ? 'up' + : delta <= -RATING_TREND_DELTA_THRESHOLD ? 'down' + : 'stable' + return { suggestion, history, trend, delta } +} + +export function numberToRating(avg: number): FeedbackRating { + if (avg < 1.5) return 'not_client_ready' + if (avg < 2.5) return 'partially_client_ready' + if (avg < 3.5) return 'nearly_client_ready' + return 'client_ready' +} + +/** Resultierende Gewichte für k chronologische Datenpunkte, z.B. previewWeights(3, 0.5) => [1, 1.5, 2]. */ +export function previewWeights(k: number, weightStep: number): number[] { + return Array.from({ length: k }, (_, i) => 1 + i * weightStep) +} diff --git a/vite.config.ts b/vite.config.ts index f884f2e..07a6409 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,10 @@ import tailwindcss from '@tailwindcss/vite' import { VitePWA } from 'vite-plugin-pwa' export default defineConfig({ + server: { + port: process.env.PORT ? Number(process.env.PORT) : 5173, + strictPort: !!process.env.PORT, + }, plugins: [ react(), tailwindcss(),