diff --git a/FIXPLAN-UI-Ranglisten.md b/FIXPLAN-UI-Ranglisten.md new file mode 100644 index 0000000..a81a358 --- /dev/null +++ b/FIXPLAN-UI-Ranglisten.md @@ -0,0 +1,154 @@ +# FIXPLAN Teil UI — Ranglisten & Showcases sichtbar/korrekt machen + +> Eigenständiger Plan für die Web-UI (`src/Predictalytics.Api/wwwroot/`). Von oben nach unten +> abarbeiten. Nach **jeder** Aufgabe im Browser gegen die laufende API prüfen und committen. + +--- + +## 0. Regeln (zuerst lesen) + +1. **Backend-Test-Baseline bleibt grün.** Vor Beginn einmal `dotnet test src/Predictalytics.Application.Tests` + laufen lassen und die aktuelle Zahl (grün/übersprungen) notieren. Keine Aufgabe hier darf sie senken. + Assertions bestehender Tests **niemals** ändern. +2. **Reine Frontend-Änderungen** (`index.html`, `app.js`, `style.css`) brauchen keine Migration. + Die zwei kleinen API-Ergänzungen (U3) sind additiv und nullable/optional. +3. **Nichts „drumherum" umbauen.** Kleinste sichere Variante wählen. Bestehende Optik/Klassen + (`card`, `data-table`, `tier-badge`, `platform-select`, CSS-Variablen) wiederverwenden, keine neuen + Farbwelten erfinden. +4. **Zahlen:** Prozente 0–100, Beträge USD, Zeiten UTC — konsistent zum restlichen UI. +5. Nach jeder Aufgabe: Browser-Check gegen `page-dashboard` bzw. `page-traders`, dann `git commit`. + +--- + +## Kontext — was ist kaputt (im Code verifiziert 2026-07-23) + +Der Commit *„Add showcase leaderboards endpoint + sortable trader list"* hat das **Backend** erweitert, +aber das **Frontend nicht nachgezogen**: + +- `GET /api/traders/showcases` liefert 7 kuratierte `ShowcaseSection` (`copy_ready`, `smooth_operators`, + `rising_stars`, `high_conviction`, `specialists`, `insider_watch`, `red_flags` — siehe + `ShowcaseBuilder.cs`). **Kein** Frontend-Code ruft das je auf → das Feature ist unsichtbar. +- `GetTradersAsync(sort)` unterstützt serverseitig `pnl`, `pnl30d`, `winrate`, `copytrading`, `calmar`, + `conviction`, `profitfactor` (`AnalyticsService.cs:184`). Das Frontend übergibt **keinen** `sort`-Param, + holt stattdessen `take=100` in Default-Order und sortiert clientseitig (`app.js:392,420`). + → Die aussagekräftigen Metriken (Calmar, Conviction, Profit-Faktor) sind nicht wählbar, und jede + Sortierung reiht nur die ersten 100 der Default-Order um statt die echten Top-N zu zeigen. +- `filterWinrateMin` / `filterCopyabilityMin` filtern clientseitig nach dem `take=100`-Fetch + (`app.js:416`) → ein passender Trader jenseits Rang 100 erscheint nie. +- Zwei Sort-Dropdowns schreiben beide `currentSort`: global `#sortSelect` (`app.js:121`) und lokal + `#tradersSort` (`index.html:209`), mit **unterschiedlichen** Optionssätzen. +- Empty-State nutzt `colspan="11"` (`app.js:407`), die Tabelle hat aber **10** Spalten (`index.html:270`). + +--- + +## Aufgabe U1 — Showcases auf dem Dashboard rendern (größter Hebel) + +**Ziel:** Die 7 kuratierten Sektionen sichtbar machen — je Sektion Titel, Beschreibung und die Top-Trader +als klickbare Kacheln/Zeilen (Klick → `viewTrader(id)`). + +- [ ] **HTML:** In `index.html` im `#page-dashboard` einen Container `
` + ergänzen (unter den bestehenden Dashboard-Karten, vor/nach „Top Traders" — Platzierung so, dass es + nicht mit den bestehenden Kacheln kollidiert). +- [ ] **JS:** Neue Funktion `loadShowcases()` in `app.js`: + - `const sections = await api('/api/traders/showcases');` + - Response-Shape (camelCase): `[{ key, title, description, traders: [TraderDto, ...] }]`. + - Pro Sektion eine `card` rendern: `title` als Überschrift, `description` als `page-subtitle`-artiger + Untertitel, darunter die Trader als kompakte Liste/Grid mit Name (+ `🤖` bei `isSuspectedBot`), + Plattform, `combinedScore`, `copytradingCopyabilityScore`, `totalPnl` (via `fmt.pnl`), Trait-Chips + (dieselbe Chip-Logik wie in `loadTraders`, `getTraitDisplayName/Description/Class`). + - Leere Sektionen liefert das Backend gar nicht erst — kein Sonderfall nötig; aber wenn die ganze + Antwort leer/`null` ist, freundlicher Empty-State. + - Jede Trader-Zeile: `onclick="viewTrader(${t.id})"`. +- [ ] `loadShowcases()` in `loadDashboard()` aufrufen (bzw. beim Aktivieren von `page-dashboard`). +- [ ] **Optik:** `insider_watch` und `red_flags` optisch abheben (z. B. Warn-Akzent für Red Flags über + vorhandene CSS-Variablen `--pnl-negative`), aber im bestehenden Kartenstil bleiben. + +**U1 fertig, wenn:** Dashboard zeigt die vom Backend gelieferten Sektionen; Klick auf einen Trader öffnet +die Detailseite; keine Konsolenfehler. Commit: „UI-U1: render dashboard showcases". + +--- + +## Aufgabe U2 — Server-seitige Sortierung statt Client-Sort auf 100 Zeilen + +**Ziel:** Die Rangliste zeigt die **echten** Top-N nach der gewählten Metrik, nicht nur eine Umsortierung +der ersten 100. + +- [ ] **JS:** In `loadTraders()` den gewählten Sort als Query-Param an die API hängen: + `url += '&sort=' + encodeURIComponent(serverSortKey)`. Mapping UI→Server: + `score→` (leer/default), `winrate→winrate`, `copyability→copytrading`, `pnl→pnl`, + `pnl30d→pnl30d`, `calmar→calmar`, `conviction→conviction`, `profitfactor→profitfactor`. + (Der Server ordnet absteigend — das ist für alle diese Metriken die sinnvolle Richtung.) +- [ ] **JS:** Den clientseitigen `data.sort(...)`-Block in `loadTraders()` entfernen bzw. nur noch als + Fallback für die rein clientseitigen Keys `name`/`platform` behalten (die kennt der Server nicht). +- [ ] **take** von 100 auf einen sinnvollen Wert erhöhen (z. B. 200) — die serverseitige Sortierung liefert + jetzt die richtige Reihenfolge, die Tabelle kann eine echte Bestenliste zeigen. +- [ ] Die Klick-Sortierung der Tabellenköpfe (`setTraderSort`, `app.js:376`) auf denselben Pfad umstellen: + Sort setzen → `loadTraders()` (das nun serverseitig sortiert). Die `↕`-Header behalten. + +**U2 fertig, wenn:** Auswahl „Calmar/Conviction/Profit-Faktor" (nach U4) verändert die Reihenfolge sichtbar +und zeigt Trader, die vorher nicht in den ersten 100 waren. Commit: „UI-U2: server-side leaderboard sort". + +--- + +## Aufgabe U3 — Min-Filter serverseitig (Winrate/Copyability) — additive API-Ergänzung + +**Ziel:** Die Kennzahl-Mindestfilter dürfen nicht an der 100/200-Grenze abschneiden. + +- [ ] **API:** `IAnalyticsService.GetTradersAsync` und die Implementierung (`AnalyticsService.cs:165`) um + zwei **optionale nullable** Parameter erweitern: `decimal? minWinRate = null, + decimal? minCopyability = null`. In der In-Memory-Filterkette (dort wird ohnehin schon `highlyCopyable` + und `traitFilter` gefiltert) anwenden: + `if (minWinRate is > 0) traders = traders.Where(t => t.WinRate >= minWinRate).ToList();` und analog + `t.Analytics?.CopytradingCopyabilityScore >= minCopyability`. +- [ ] **Endpoint:** In `TraderEndpoints.cs` die `/api/traders`-Route um die zwei Query-Parameter durchreichen. +- [ ] **JS:** In `loadTraders()` `filterWinrateMin`/`filterCopyabilityMin` als Query-Param senden statt + clientseitig zu filtern; den clientseitigen Filter-Block entfernen. +- [ ] **Test:** Kleiner Service-Test (SQLite-Muster wie vorhandene `AnalyticsService`/Repository-Tests): + drei Trader mit WinRate 40/60/80, `minWinRate=55` → nur zwei zurück. Baseline bleibt grün. + +**U3 fertig, wenn:** Ein hoher Mindest-Copyability-Wert zeigt auch Trader, die in der Default-Order weit +hinten stehen. Commit: „UI-U3: server-side min-winrate/min-copyability filters". + +--- + +## Aufgabe U4 — Sort-Dropdown vereinheitlichen & vervollständigen + +**Ziel:** Ein einziges, vollständiges Sort-Steuerelement für die Rangliste. + +- [ ] **HTML:** `#tradersSort` (`index.html:209`) um die aussagekräftigen Metriken erweitern: + `pnl30d` (PnL 30 T), `calmar` (Rendite/Drawdown), `conviction` (Conviction-Edge), + `profitfactor` (Profit-Faktor), `copyability` (bereits da), `score`/`winrate`/`pnl`/`name` behalten. +- [ ] **JS:** Klarstellen, welches Dropdown die Traders-Seite steuert. `#tradersSort` bleibt maßgeblich für + `page-traders`; das globale `#sortSelect` (falls es die Traders-Seite mitsteuert) entkoppeln oder + auf denselben State spiegeln, sodass es **kein** widersprüchliches `currentSort` mehr gibt. +- [ ] Sicherstellen, dass Header-Klick (`setTraderSort`) und Dropdown denselben `currentSort` schreiben und + beide `loadTraders()` (serverseitig, U2) auslösen. + +**U4 fertig, wenn:** Alle Dropdown-Optionen sortieren korrekt (serverseitig), Header-Klick und Dropdown +bleiben synchron. Commit: „UI-U4: unify + extend leaderboard sort control". + +--- + +## Aufgabe U5 — Kleinkram / Konsistenz + +- [ ] **colspan-Fix:** Empty-State in `loadTraders()` (`app.js:407`) von `colspan="11"` auf die echte + Spaltenzahl **10** korrigieren. +- [ ] **Spalten-Parität:** Prüfen, dass Tabellenkopf (`index.html:270`) und Zeilen-Template + (`app.js:435`) dieselbe Spaltenzahl/-reihenfolge haben. +- [ ] **Optional:** Aktive Sortierspalte im Header visuell markieren (Pfeilrichtung ▲/▼ statt neutralem ↕). + +**U5 fertig, wenn:** Leere Tabelle rendert sauber über die volle Breite; kein Spaltenversatz. +Commit: „UI-U5: leaderboard table consistency". + +--- + +## Gesamt-Abnahme + +1. `dotnet build` fehlerfrei; `dotnet test` = notierte Baseline weiter grün + neuer U3-Test grün. +2. Dashboard zeigt die 7 Showcase-Sektionen mit echten Tradern (U1). +3. Sortierung nach Calmar/Conviction/Profit-Faktor zeigt Trader jenseits der alten Top-100 (U2/U4). +4. Mindest-Filter schneiden nicht mehr an der Fetch-Grenze ab (U3). +5. Keine JS-Konsolenfehler; Tabelle ohne Spaltenversatz (U5). + +**Reihenfolge:** U1 (Sichtbarkeit, reines Frontend) → U2 (Sort-Korrektheit) → U3 (Filter-Korrektheit, +kleine API-Ergänzung) → U4 (Bedien-Konsistenz) → U5 (Politur). diff --git a/src/Predictalytics.Api/wwwroot/css/style.css b/src/Predictalytics.Api/wwwroot/css/style.css index 9c412d7..7f6bfc3 100644 --- a/src/Predictalytics.Api/wwwroot/css/style.css +++ b/src/Predictalytics.Api/wwwroot/css/style.css @@ -1106,4 +1106,84 @@ a:hover { color: #8ab8ff; } .detail-grid { grid-template-columns: 1fr; } + .showcase-wrap { + grid-template-columns: 1fr; + } +} + +/* ─── Curated Showcases (dashboard leaderboards) ─── */ +.showcase-wrap { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.showcase-card { + padding: 18px 18px 8px; +} +.showcase-card--warn { + border-color: var(--danger-glow); + box-shadow: 0 0 0 1px var(--danger-glow) inset; +} +.showcase-header { + margin-bottom: 12px; +} +.showcase-header h2 { + font-size: 15px; + font-weight: 700; +} +.showcase-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 3px; + line-height: 1.4; +} +.showcase-list { + display: flex; + flex-direction: column; +} +.showcase-row { + display: grid; + grid-template-columns: 22px 1fr auto auto auto; + align-items: center; + gap: 10px; + padding: 9px 4px; + border-top: 1px solid var(--border); + cursor: pointer; + transition: var(--transition); +} +.showcase-row:hover { + background: var(--bg-card-hover); +} +.showcase-rank { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-darker); + text-align: center; +} +.showcase-name { + font-weight: 600; + font-size: 13px; + color: var(--text-primary); + min-width: 0; + overflow: hidden; +} +.showcase-platform { + font-size: 11px; + color: var(--text-muted); + font-weight: 500; + margin-left: 6px; +} +.showcase-metric { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + text-align: right; + white-space: nowrap; +} +.showcase-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; } diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index c7d85d7..81e4db5 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -156,6 +156,9 @@ + +
+
@@ -208,8 +211,12 @@
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js index 16b7501..539f77c 100644 --- a/src/Predictalytics.Api/wwwroot/js/app.js +++ b/src/Predictalytics.Api/wwwroot/js/app.js @@ -300,6 +300,9 @@ async function loadDashboard() { return; } + // Curated showcases (loads in parallel; independent of the rest of the dashboard). + loadShowcases(); + // Metrics document.getElementById('metricTraders').textContent = fmt.num(data.totalTraders); document.getElementById('metricActive').textContent = fmt.num(data.activeTraders24h); @@ -373,6 +376,53 @@ async function loadDashboard() { }); } +// ─── Curated Showcases (dashboard leaderboards) ─── +function renderTraitChips(traits) { + if (!traits || !traits.length) return ''; + return '
' + traits.map(tr => { + const rawKey = tr.trait || tr; + const name = getTraitDisplayName(rawKey); + const desc = getTraitDescription(rawKey); + return `${name}`; + }).join('') + '
'; +} + +async function loadShowcases() { + const wrap = document.getElementById('showcaseSections'); + if (!wrap) return; + + const sections = await api('/api/traders/showcases'); + if (!Array.isArray(sections) || sections.length === 0) { + wrap.innerHTML = '

Noch keine Showcases verfügbar.

'; + return; + } + + wrap.innerHTML = sections.map(s => { + const flag = s.key === 'red_flags' ? ' showcase-card--warn' : ''; + const rows = (s.traders || []).map((t, i) => ` +
+ ${i + 1} + ${t.displayName}${t.isSuspectedBot ? ' 🤖' : ''} + ${t.platform} + ${renderTraitChips(t.traits)} + + ${Number(t.combinedScore).toFixed(1)} + ${Number(t.copytradingCopyabilityScore || 0).toFixed(0)} + ${fmt.pnl(t.totalPnl)} +
+ `).join(''); + return ` +
+
+

${s.title}

+
${s.description}
+
+
${rows}
+
+ `; + }).join(''); +} + function setTraderSort(field) { if (currentSort === field) { sortDirection *= -1; @@ -388,10 +438,34 @@ function setTraderSort(field) { loadTraders(); } +// Maps a UI sort key to the server-side sort parameter understood by GetTradersAsync. +// Keys that map to '' use the repository default order (CombinedScore, then PnL) or are +// sorted client-side (name/platform — the server has no key for them). +const SERVER_SORT_KEYS = { + score: '', // default order == combined score + winrate: 'winrate', + quality: 'copytrading', // no separate quality key server-side; closest is copytrading score + copyability: 'copytrading', + pnl: 'pnl', + pnl30d: 'pnl30d', + calmar: 'calmar', + conviction: 'conviction', + profitfactor: 'profitfactor', + name: '', + platform: '' +}; + async function loadTraders() { - let url = '/api/traders?skip=0&take=100'; + // Keep currentSort in sync with the dropdown BEFORE building the query so the server sorts. + const sortSelect = document.getElementById('tradersSort'); + if (sortSelect && sortSelect.value !== currentSort) currentSort = sortSelect.value; + + let url = '/api/traders?skip=0&take=200'; if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`; - + + const serverSort = SERVER_SORT_KEYS[currentSort]; + if (serverSort) url += `&sort=${encodeURIComponent(serverSort)}`; + const hcCheckbox = document.getElementById('chk-highly-copyable'); if (hcCheckbox && hcCheckbox.checked) { url += `&highlyCopyable=true`; @@ -402,35 +476,19 @@ async function loadTraders() { url += `&trait=${encodeURIComponent(filterTrait.value)}`; } - let data = await api(url); - const tbody = document.getElementById('allTradersBody'); - if (!data || !data.length) { tbody.innerHTML = '

No traders tracked yet.

'; return; } - - const sortSelect = document.getElementById('tradersSort'); - if (sortSelect && sortSelect.value !== currentSort) currentSort = sortSelect.value; - + // Min-value filters are applied server-side so they are not blind to the fetch cut-off. const filterWinrateMin = parseFloat(document.getElementById('filterWinrateMin')?.value); const filterCopyabilityMin = parseFloat(document.getElementById('filterCopyabilityMin')?.value); + if (!isNaN(filterWinrateMin)) url += `&minWinRate=${filterWinrateMin}`; + if (!isNaN(filterCopyabilityMin)) url += `&minCopyability=${filterCopyabilityMin}`; - // Filtering - if (!isNaN(filterWinrateMin)) data = data.filter(t => t.winRate >= filterWinrateMin); - if (!isNaN(filterCopyabilityMin)) data = data.filter(t => t.copytradingCopyabilityScore >= filterCopyabilityMin); + let data = await api(url); + const tbody = document.getElementById('allTradersBody'); + if (!data || !data.length) { tbody.innerHTML = '

No traders tracked yet.

'; return; } - // Sorting - data.sort((a, b) => { - let valA, valB; - if (currentSort === 'score') { valA = a.combinedScore; valB = b.combinedScore; } - else if (currentSort === 'quality') { valA = a.copytradingQualityScore || 0; valB = b.copytradingQualityScore || 0; } - else if (currentSort === 'copyability') { valA = a.copytradingCopyabilityScore || 0; valB = b.copytradingCopyabilityScore || 0; } - else if (currentSort === 'winrate') { valA = a.winRate; valB = b.winRate; } - else if (currentSort === 'pnl') { valA = a.totalPnl; valB = b.totalPnl; } - else if (currentSort === 'trades') { valA = a.trades30d; valB = b.trades30d; } - else if (currentSort === 'name') { return a.displayName.localeCompare(b.displayName) * sortDirection; } - else if (currentSort === 'platform') { return a.platform.localeCompare(b.platform) * sortDirection; } - else { valA = a.combinedScore; valB = b.combinedScore; } - - return (valA < valB ? -1 : valA > valB ? 1 : 0) * sortDirection; - }); + // Only name/platform need client-side ordering; every other key is already sorted by the server. + if (currentSort === 'name') data.sort((a, b) => a.displayName.localeCompare(b.displayName) * sortDirection); + else if (currentSort === 'platform') data.sort((a, b) => a.platform.localeCompare(b.platform) * sortDirection); tbody.innerHTML = data.map((t, i) => `