feat(scrobble): Head-to-Head "Musik-Match" — compare two users' listening

Adds GET /scrobble/users (distinct users for the partner picker) and
GET /scrobble/compare?with= to the scrobble service. Taste match = cosine
of the two users' artist-play vectors (0..100); also returns shared artists
(mirrored bars), shared tracks, and each side's unique top artists. Reuses
the existing per-user play-log — no new data capture.

Web UI src/ui/compare.js (overlay from Settings "🎧 Musik-Match"): partner
picker fed by /scrobble/users, match-score ring, per-user totals, shared +
unique lists with clickable artist/album rows. Cosine + shared logic verified
against a hand computation on an in-memory DB. Android pending (parity: 🔜).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-13 00:46:29 +02:00
1 parent ba2306656e
commit cbedbba6fc
6 files changed
+317 -1

No files matched your search

+1
View File
@@ -57,6 +57,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Timestamped play-log (own scrobble) | ✅ | ✅ | | Timestamped play-log (own scrobble) | ✅ | ✅ |
| Listening statistics view | ✅ | ✅ | | Listening statistics view | ✅ | ✅ |
| Stats cover art (artist/album) | ✅ | ✅ | | Stats cover art (artist/album) | ✅ | ✅ |
| Musik-Match (Head-to-Head) | ✅ | 🔜 |
| Yearly Wrapped | 🔜 | 🔜 | | Yearly Wrapped | 🔜 | 🔜 |
| Romaji / Japanese sort | ✅ | ❌ | | Romaji / Japanese sort | ✅ | ❌ |
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ | | Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
+1
View File
@@ -713,6 +713,7 @@
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button> <button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button>
</div> </div>
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openStats()">📊 Meine Statistiken</button> <button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openStats()">📊 Meine Statistiken</button>
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openCompare()">🎧 Musik-Match</button>
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid rgba(224,85,85,0.3);color:var(--danger);transition:background .15s" onmouseover="this.style.background='rgba(224,85,85,0.1)'" onmouseout="this.style.background='none'" onclick="confirmDisconnect()">Abmelden</button> <button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid rgba(224,85,85,0.3);color:var(--danger);transition:background .15s" onmouseover="this.style.background='rgba(224,85,85,0.1)'" onmouseout="this.style.background='none'" onclick="confirmDisconnect()">Abmelden</button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v76'; const CACHE = 'ns-v77';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+89
View File
@@ -251,6 +251,95 @@ app.get('/scrobble/stats', async (req, res) => {
}); });
}); });
// GET /scrobble/users — distinct users in the log, for the Head-to-Head partner picker.
// Any valid Navidrome login may list them (so you can pick who to compare against).
app.get('/scrobble/users', async (req, res) => {
const me = await verifyUser(req);
if (!me) return res.status(401).json({ error: 'unauthorized' });
const rows = db.prepare(`
SELECT user, COUNT(*) AS plays, MAX(played_at) AS lastPlayedAt
FROM plays GROUP BY user ORDER BY plays DESC
`).all();
res.json({ me, users: rows });
});
// GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs
// another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100).
// Also returns shared artists/tracks and each side's unique top artists.
app.get('/scrobble/compare', async (req, res) => {
const userA = await verifyUser(req);
if (!userA) return res.status(401).json({ error: 'unauthorized' });
const userB = String(req.query.with || '').trim();
if (!userB) return res.status(400).json({ error: 'missing ?with=' });
if (userB === userA) return res.status(400).json({ error: 'same user' });
// Window (default all-time). Parse `from` directly — _toEpochSec maps 0 → now (see /stats).
const fromN = Number(req.query.from);
const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 0;
const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1;
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
const artistsOf = u => db.prepare(`
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(artist)
`).all({ user: u, from, to });
const tracksOf = u => db.prepare(`
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
FROM plays WHERE ${WHERE} GROUP BY song_id
`).all({ user: u, from, to });
const totalsOf = u => db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
).get({ user: u, from, to });
const aArtists = artistsOf(userA), bArtists = artistsOf(userB);
const aMap = new Map(aArtists.map(r => [r.k, r]));
const bMap = new Map(bArtists.map(r => [r.k, r]));
// Cosine similarity over the artist-play vectors → a stable 0..100 "match".
let dot = 0, nA = 0, nB = 0;
for (const r of aArtists) nA += r.plays * r.plays;
for (const r of bArtists) nB += r.plays * r.plays;
for (const [k, ra] of aMap) { const rb = bMap.get(k); if (rb) dot += ra.plays * rb.plays; }
const matchScore = (nA && nB) ? Math.round((dot / (Math.sqrt(nA) * Math.sqrt(nB))) * 100) : 0;
const sharedArtists = [];
for (const [k, ra] of aMap) {
const rb = bMap.get(k);
if (rb) sharedArtists.push({ name: ra.name, artistId: ra.artistId || rb.artistId, playsA: ra.plays, playsB: rb.plays });
}
sharedArtists.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
const onlyTop = (mine, other) => mine
.filter(r => !other.has(r.k))
.sort((x, y) => y.plays - x.plays).slice(0, 8)
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
const aTracks = tracksOf(userA);
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
const sharedTracks = [];
for (const ta of aTracks) {
const tb = bTrackMap.get(ta.songId);
if (tb) sharedTracks.push({
songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId,
albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays,
});
}
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
res.json({
userA, userB, range: { from, to },
matchScore,
totalsA: { ...totalsOf(userA), artists: aArtists.length },
totalsB: { ...totalsOf(userB), artists: bArtists.length },
sharedArtists: sharedArtists.slice(0, 30),
sharedTracks: sharedTracks.slice(0, 30),
onlyA: onlyTop(aArtists, bMap),
onlyB: onlyTop(bArtists, aMap),
});
});
// DELETE /scrobble/plays?user=NAME — purge one user's rows (for test cleanup). // DELETE /scrobble/plays?user=NAME — purge one user's rows (for test cleanup).
// Requires ANY valid Navidrome login (a deleted user can't authenticate as itself, // Requires ANY valid Navidrome login (a deleted user can't authenticate as itself,
// so cleanup after deletion must be authorized by a currently-valid account). // so cleanup after deletion must be authorized by a currently-valid account).
+2
View File
@@ -4,6 +4,7 @@ import { api, fetchRomajiIndex, loadRomajiOverrides, loadArtistOverrides } from
import { injectPWA } from './pwa.js'; import { injectPWA } from './pwa.js';
import { initPlayLog } from './player/scrobbleLog.js'; import { initPlayLog } from './player/scrobbleLog.js';
import { openStats, closeStats } from './ui/stats.js'; import { openStats, closeStats } from './ui/stats.js';
import { openCompare, closeCompare } from './ui/compare.js';
import { closeArtistPin, clearArtistPin } from './ui/artistPin.js'; import { closeArtistPin, clearArtistPin } from './ui/artistPin.js';
import { loadSavedTheme } from './themes.js'; import { loadSavedTheme } from './themes.js';
import { initScrollRows } from './ui/components.js'; import { initScrollRows } from './ui/components.js';
@@ -336,6 +337,7 @@ Object.assign(window, {
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
openSidebar, closeSidebar, switchNavMode, showCtxMenu, openSidebar, closeSidebar, switchNavMode, showCtxMenu,
openStats, closeStats, openStats, closeStats,
openCompare, closeCompare,
closeArtistPin, clearArtistPin, closeArtistPin, clearArtistPin,
// Views // Views
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
+223
View File
@@ -0,0 +1,223 @@
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
// Opened from Settings ("Musik-Match"). Compares the logged-in user's play-log
// against another user's (GET /scrobble/compare) and renders a taste-match score
// (cosine over artist-play vectors), shared artists/tracks, and each side's unique
// top artists. The partner is chosen from a picker fed by GET /scrobble/users.
// Self-contained overlay, mirroring stats.js.
import { S } from '../state.js';
import { esc } from '../utils.js';
import { subsonicAuthParams, coverUrl } from '../api.js';
let _overlay = null;
let _me = null; // authed username (userA)
let _partner = null; // chosen userB
let _users = []; // [{ user, plays, lastPlayedAt }]
export function openCompare() {
if (!_overlay) _buildOverlay();
_overlay.classList.add('open');
document.body.style.overflow = 'hidden';
_loadUsers();
}
export function closeCompare() {
if (_overlay) _overlay.classList.remove('open');
document.body.style.overflow = '';
}
// Called from the rendered partner list (delegated below) — kept module-local.
function _choosePartner(name) { _partner = name; _loadCompare(); }
function _buildOverlay() {
_overlay = document.createElement('div');
_overlay.className = 'modal-overlay';
_overlay.id = 'compare-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); });
_overlay.innerHTML = `
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:18px">🎧</span>
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
<button id="compare-back" title="Andere Person" style="display:none;padding:6px 10px;color:var(--text2);font-size:14px;font-weight:600;background:none;border:none;cursor:pointer">↺ Wechseln</button>
<button class="hbtn" onclick="closeCompare()" title="Schließen"
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
</div>
<div class="compare-body" id="compare-body" style="padding:16px 18px;overflow-y:auto;min-height:50vh"></div>
</div>`;
// Delegated: pick a partner, or navigate to an artist/album row.
_overlay.querySelector('#compare-body').addEventListener('click', async e => {
const pick = e.target.closest('[data-partner]');
if (pick) { _choosePartner(pick.dataset.partner); return; }
const row = e.target.closest('[data-nav-type]');
if (!row) return;
const { navType, navId, navName, navArtist } = row.dataset;
closeCompare();
const { navigate } = await import('../views/views.js');
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
});
_overlay.querySelector('#compare-back').addEventListener('click', () => { _partner = null; _renderPicker(); });
document.body.appendChild(_overlay);
}
function _errBox(msg) {
return `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
}
function _info(msg) {
return `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">${msg}</div>`;
}
async function _loadUsers() {
const body = _overlay.querySelector('#compare-body');
_overlay.querySelector('#compare-back').style.display = 'none';
_partner = null;
if (!S.cfg.scrobbleUrl) {
body.innerHTML = _info('Kein Scrobble-Dienst konfiguriert.<br>Trage die <b>Scrobble-URL</b> in den Einstellungen ein.');
return;
}
const auth = subsonicAuthParams();
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
body.innerHTML = _info('Lade…');
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/users?${auth}`);
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
const d = await res.json();
_me = d.me;
_users = (d.users || []).filter(u => u.user !== _me);
_renderPicker();
} catch {
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
}
}
function _renderPicker() {
const body = _overlay.querySelector('#compare-body');
_overlay.querySelector('#compare-back').style.display = 'none';
if (!_users.length) {
body.innerHTML = _info('Noch niemand sonst hat Wiedergaben aufgezeichnet.<br>Sobald eine andere Person NovaSonic nutzt, kannst du euch hier vergleichen.');
return;
}
const rows = _users.map(u => `
<div data-partner="${esc(u.user)}" style="display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:12px;cursor:pointer;border:1px solid var(--border);margin-bottom:8px"
onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'">
<div style="width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-weight:800;color:#000;flex-shrink:0">${esc((u.user[0] || '?').toUpperCase())}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(u.user)}</div>
<div style="font-size:12px;color:var(--text2)">${u.plays.toLocaleString('de-DE')} Plays</div>
</div>
<div style="color:var(--text2);font-size:18px">›</div>
</div>`).join('');
body.innerHTML = `<div style="font-size:13px;color:var(--text2);margin:2px 2px 14px">Mit wem willst du dich vergleichen, <b>${esc(_me || '')}</b>?</div>${rows}`;
}
async function _loadCompare() {
const body = _overlay.querySelector('#compare-body');
_overlay.querySelector('#compare-back').style.display = 'inline-block';
const auth = subsonicAuthParams();
body.innerHTML = _info('Vergleiche…');
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/compare?${auth}&with=${encodeURIComponent(_partner)}`);
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
_renderCompare(body, await res.json());
} catch {
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
}
}
function _renderCompare(body, d) {
const score = Math.max(0, Math.min(100, d.matchScore || 0));
const verdict =
score >= 80 ? 'Seelenverwandte 🎯' :
score >= 60 ? 'Starkes Match 🔥' :
score >= 40 ? 'Solide Überschneidung 👍' :
score >= 20 ? 'Ein paar Gemeinsamkeiten 🙂' :
'Ziemlich unterschiedlich 🌗';
body.innerHTML = [
_scoreRing(d, score, verdict),
_totals(d),
_sharedArtists(d.sharedArtists),
_sharedTracks(d.sharedTracks),
_uniqueCols(d),
].join('');
}
function _scoreRing(d, score, verdict) {
const deg = Math.round(score * 3.6);
return `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px">
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center">
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center">
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div>
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div>
</div>
</div>
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div>
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> &nbsp;⟷&nbsp; <b>${esc(d.userB)}</b></div>
</div>`;
}
function _totals(d) {
const col = (name, t) => `
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px 14px;min-width:0">
<div style="font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px">${esc(name)}</div>
<div style="font-size:12px;color:var(--text2)">${(t.plays || 0).toLocaleString('de-DE')} Plays · ${(t.artists || 0).toLocaleString('de-DE')} Künstler</div>
</div>`;
return `<div style="display:flex;gap:10px;margin-bottom:22px">${col(d.userA, d.totalsA)}${col(d.userB, d.totalsB)}</div>`;
}
function _h(t) {
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 10px">${esc(t)}</div>`;
}
// Shared artist row: a centered name with each side's play count on its flank + a
// mirrored bar so you can see who plays them more.
function _sharedArtists(items) {
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
const rows = items.slice(0, 15).map(a => {
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0">
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div>
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div>
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div>
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div>
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div>
</div>`;
}).join('');
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`;
}
function _sharedTracks(items) {
if (!items?.length) return '';
const rows = items.slice(0, 12).map(t => {
const cover = coverUrl(t.coverArt || t.albumId || t.songId, 80);
const nav = t.albumId ? `data-nav-type="album" data-nav-id="${esc(t.albumId)}" data-nav-name="${esc(t.album || '')}" data-nav-artist="${esc(t.artist || '')}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:6px 0">
<div style="width:40px;height:40px;border-radius:6px;background:var(--surface2);flex-shrink:0;overflow:hidden">
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.title || '—')}</div>
<div style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.artist || '')}</div>
</div>
<div style="font-size:12px;color:var(--text2);font-weight:600;white-space:nowrap">${t.playsA} · ${t.playsB}</div>
</div>`;
}).join('');
return `${_h('Gemeinsame Songs')}<div style="margin-bottom:22px">${rows}</div>`;
}
function _uniqueCols(d) {
const list = (title, arr) => {
const rows = (arr || []).slice(0, 6).map(a => {
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px">
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.name)}</span>
<span style="color:var(--text2);font-size:12px;font-weight:600">${a.plays}</span>
</div>`;
}).join('') || `<div style="color:var(--text2);font-size:13px;padding:4px 0">—</div>`;
return `<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:800;color:var(--text2);margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(title)}</div>${rows}</div>`;
};
return `${_h('Nur bei…')}<div style="display:flex;gap:18px">${list(`Nur ${d.userA}`, d.onlyA)}${list(`Nur ${d.userB}`, d.onlyB)}</div>`;
}