rspace-online/modules/rcred/components/folk-cred-dashboard.ts

459 lines
16 KiB
TypeScript

/**
* <folk-cred-dashboard> — CredRank contribution recognition dashboard.
*
* Leaderboard, per-contributor breakdown, and admin weight config.
* Fetches from /:space/rcred/api/* endpoints.
*/
import { authFetch } from '../../../shared/auth-fetch';
interface LeaderboardEntry {
rank: number;
did: string;
label: string;
cred: number;
grainLifetime: number;
topModule: string;
lastActive: number;
}
interface ScoresResponse {
scores: LeaderboardEntry[];
totalCred: number;
computedAt: number;
epochId: string;
}
interface ContributorDetail {
did: string;
label: string;
cred: number;
rawScore: number;
grainLifetime: number;
breakdown: Record<string, number>;
epochScores: Record<string, number>;
lastActive: number;
}
interface WeightConfig {
type: string;
weight: number;
description: string;
}
interface ConfigResponse {
weights: Record<string, WeightConfig>;
grainPerEpoch: number;
epochLengthDays: number;
slowFraction: number;
fastFraction: number;
dampingFactor: number;
lookbackDays: number;
enabled: boolean;
lastEpochAt: number;
}
const MODULE_COLORS: Record<string, string> = {
rtasks: '#3b82f6',
rdocs: '#8b5cf6',
rchats: '#06b6d4',
rcal: '#f59e0b',
rvote: '#ec4899',
rflows: '#22c55e',
rtime: '#a78bfa',
rwallet: '#d97706',
};
const MODULE_LABELS: Record<string, string> = {
rtasks: 'Tasks',
rdocs: 'Docs',
rchats: 'Chats',
rcal: 'Calendar',
rvote: 'Voting',
rflows: 'Flows',
rtime: 'Time',
rwallet: 'Wallet',
};
class FolkCredDashboard extends HTMLElement {
private shadow: ShadowRoot;
private space = '';
private view: 'leaderboard' | 'contributor' | 'config' = 'leaderboard';
private scores: LeaderboardEntry[] = [];
private totalCred = 0;
private computedAt = 0;
private selectedContributor: ContributorDetail | null = null;
private config: ConfigResponse | null = null;
private loading = false;
private recomputing = false;
private error = '';
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
this.loadScores();
}
private async loadScores() {
this.loading = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/scores?space=${this.space}`);
const data: ScoresResponse = await res.json();
this.scores = data.scores || [];
this.totalCred = data.totalCred || 0;
this.computedAt = data.computedAt || 0;
this.error = '';
} catch (e) {
this.error = 'Failed to load scores';
}
this.loading = false;
this.render();
}
private async loadContributor(did: string) {
this.loading = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/scores/${encodeURIComponent(did)}?space=${this.space}`);
if (!res.ok) throw new Error('Not found');
this.selectedContributor = await res.json();
this.view = 'contributor';
this.error = '';
} catch (e) {
this.error = 'Contributor not found';
}
this.loading = false;
this.render();
}
private async loadConfig() {
this.loading = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/config?space=${this.space}`);
this.config = await res.json();
this.view = 'config';
this.error = '';
} catch (e) {
this.error = 'Failed to load config';
}
this.loading = false;
this.render();
}
private async triggerRecompute() {
this.recomputing = true;
this.render();
try {
const res = await authFetch(`/${this.space}/rcred/api/recompute?space=${this.space}`, { method: 'POST' });
const result = await res.json();
if (result.success) {
await this.loadScores();
} else {
this.error = result.error || 'Recompute failed';
}
} catch (e) {
this.error = 'Recompute failed';
}
this.recomputing = false;
this.render();
}
private async saveWeights() {
if (!this.config) return;
try {
await authFetch(`/${this.space}/rcred/api/config?space=${this.space}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ weights: this.config.weights }),
});
this.error = '';
} catch (e) {
this.error = 'Failed to save config';
}
this.render();
}
private render() {
const timeAgo = (ts: number) => {
if (!ts) return 'never';
const diff = Date.now() - ts;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
};
let content = '';
if (this.view === 'leaderboard') {
content = this.renderLeaderboard(timeAgo);
} else if (this.view === 'contributor') {
content = this.renderContributor(timeAgo);
} else if (this.view === 'config') {
content = this.renderConfig();
}
this.shadow.innerHTML = `
<style>
:host { display:block; font-family:system-ui,-apple-system,sans-serif; color:#e2e8f0; }
* { box-sizing:border-box; margin:0; padding:0; }
.container { max-width:900px; margin:0 auto; padding:1.5rem; }
.header { display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem; flex-wrap:wrap; gap:0.75rem; }
.header h1 { font-size:1.5rem; display:flex; align-items:center; gap:0.5rem; }
.header-actions { display:flex; gap:0.5rem; align-items:center; }
.meta { font-size:0.75rem; color:#64748b; margin-bottom:1rem; }
button { background:#1e293b; color:#e2e8f0; border:1px solid #334155; border-radius:0.375rem;
padding:0.4rem 0.75rem; font-size:0.8rem; cursor:pointer; transition:background 0.15s; }
button:hover { background:#334155; }
button.primary { background:linear-gradient(to right,#f59e0b,#d97706); border:none; color:#000; font-weight:600; }
button.primary:hover { opacity:0.9; }
button:disabled { opacity:0.5; cursor:not-allowed; }
.tab-bar { display:flex; gap:0.25rem; margin-bottom:1rem; }
.tab { padding:0.4rem 0.75rem; border-radius:0.375rem; cursor:pointer; font-size:0.8rem;
background:transparent; border:1px solid transparent; color:#94a3b8; }
.tab:hover { background:#1e293b; }
.tab.active { background:#1e293b; color:#fbbf24; border-color:#334155; }
table { width:100%; border-collapse:collapse; font-size:0.85rem; }
th { text-align:left; padding:0.5rem 0.75rem; color:#94a3b8; font-weight:500; border-bottom:1px solid #1e293b; font-size:0.75rem; text-transform:uppercase; letter-spacing:0.05em; }
td { padding:0.6rem 0.75rem; border-bottom:1px solid rgba(30,41,59,0.5); }
tr:hover td { background:rgba(30,41,59,0.4); }
tr { cursor:pointer; }
.rank-cell { color:#fbbf24; font-weight:700; width:3rem; text-align:center; }
.cred-cell { color:#fbbf24; font-weight:600; }
.grain-cell { color:#d97706; }
.module-badge { display:inline-block; padding:0.1rem 0.4rem; border-radius:0.25rem; font-size:0.7rem; font-weight:500; }
.empty { text-align:center; padding:3rem; color:#64748b; }
.empty h3 { font-size:1.1rem; color:#94a3b8; margin-bottom:0.5rem; }
.error { background:rgba(239,68,68,0.1); border:1px solid rgba(239,68,68,0.3); border-radius:0.375rem; padding:0.5rem 0.75rem; color:#f87171; font-size:0.8rem; margin-bottom:1rem; }
.loading { text-align:center; padding:2rem; color:#64748b; }
/* Contributor detail */
.detail-header { margin-bottom:1.5rem; }
.detail-header h2 { font-size:1.3rem; margin-bottom:0.25rem; }
.detail-stats { display:flex; gap:1.5rem; margin-top:0.75rem; flex-wrap:wrap; }
.stat-card { background:#0f172a; border:1px solid #1e293b; border-radius:0.5rem; padding:0.75rem 1rem; min-width:120px; }
.stat-value { font-size:1.5rem; font-weight:700; }
.stat-label { font-size:0.7rem; color:#64748b; text-transform:uppercase; letter-spacing:0.05em; margin-top:0.25rem; }
.breakdown { margin-top:1.5rem; }
.breakdown h3 { font-size:0.95rem; margin-bottom:0.75rem; color:#94a3b8; }
.bar-row { display:flex; align-items:center; gap:0.5rem; margin-bottom:0.4rem; }
.bar-label { width:60px; font-size:0.75rem; color:#94a3b8; text-align:right; }
.bar-track { flex:1; height:20px; background:#0f172a; border-radius:0.25rem; overflow:hidden; }
.bar-fill { height:100%; border-radius:0.25rem; display:flex; align-items:center; padding-left:0.4rem; font-size:0.65rem; font-weight:600; min-width:fit-content; }
.bar-pct { width:40px; font-size:0.75rem; color:#64748b; }
/* Config */
.weight-row { display:flex; align-items:center; gap:0.75rem; margin-bottom:0.5rem; padding:0.4rem 0; }
.weight-label { width:160px; font-size:0.8rem; color:#94a3b8; }
.weight-slider { flex:1; -webkit-appearance:none; height:6px; background:#1e293b; border-radius:3px; outline:none; }
.weight-slider::-webkit-slider-thumb { -webkit-appearance:none; width:16px; height:16px; border-radius:50%; background:#f59e0b; cursor:pointer; }
.weight-value { width:40px; text-align:right; font-size:0.85rem; font-weight:600; color:#fbbf24; }
@media (max-width:640px) {
.container { padding:1rem; }
.detail-stats { gap:0.75rem; }
.stat-card { min-width:90px; padding:0.5rem 0.75rem; }
.stat-value { font-size:1.2rem; }
}
</style>
<div class="container">${content}</div>`;
// Re-attach event listeners
this.shadow.querySelectorAll('[data-action]').forEach(el => {
el.addEventListener('click', (e) => {
const action = (e.currentTarget as HTMLElement).dataset.action!;
if (action === 'recompute') this.triggerRecompute();
else if (action === 'config') this.loadConfig();
else if (action === 'leaderboard') { this.view = 'leaderboard'; this.render(); }
else if (action.startsWith('contributor:')) this.loadContributor(action.slice(12));
else if (action === 'save-weights') this.saveWeights();
});
});
// Weight slider listeners
this.shadow.querySelectorAll('input[data-weight]').forEach(el => {
el.addEventListener('input', (e) => {
const input = e.target as HTMLInputElement;
const key = input.dataset.weight!;
if (this.config?.weights[key]) {
this.config.weights[key].weight = parseFloat(input.value);
const valueEl = this.shadow.querySelector(`[data-weight-value="${key}"]`);
if (valueEl) valueEl.textContent = input.value;
}
});
});
}
private renderLeaderboard(timeAgo: (ts: number) => string): string {
if (this.loading) return '<div class="loading">Loading scores...</div>';
const hasScores = this.scores.length > 0;
return `
<div class="header">
<h1><span style="font-size:1.8rem">⭐</span> Contribution Recognition</h1>
<div class="header-actions">
<button data-action="config">Weights</button>
<button class="primary" data-action="recompute" ${this.recomputing ? 'disabled' : ''}>
${this.recomputing ? 'Computing...' : 'Recompute'}
</button>
</div>
</div>
${this.error ? `<div class="error">${this.error}</div>` : ''}
<div class="meta">
${hasScores ? `Last computed: ${timeAgo(this.computedAt)} · ${this.scores.length} contributors · ${this.totalCred} total cred` : ''}
</div>
${hasScores ? `
<table>
<thead>
<tr>
<th style="width:3rem;text-align:center">#</th>
<th>Contributor</th>
<th style="text-align:right">Cred ⭐</th>
<th style="text-align:right">Grain 🌾</th>
<th>Top Module</th>
<th style="text-align:right">Active</th>
</tr>
</thead>
<tbody>
${this.scores.map(s => `
<tr data-action="contributor:${s.did}">
<td class="rank-cell">${s.rank}</td>
<td style="font-weight:500">${this.escapeHtml(s.label || s.did.slice(0, 20))}</td>
<td class="cred-cell" style="text-align:right">${s.cred}</td>
<td class="grain-cell" style="text-align:right">${s.grainLifetime}</td>
<td>
${s.topModule ? `<span class="module-badge" style="background:${MODULE_COLORS[s.topModule] || '#334155'}22;color:${MODULE_COLORS[s.topModule] || '#94a3b8'}">${MODULE_LABELS[s.topModule] || s.topModule}</span>` : '—'}
</td>
<td style="text-align:right;color:#64748b;font-size:0.8rem">${timeAgo(s.lastActive)}</td>
</tr>`).join('')}
</tbody>
</table>
` : `
<div class="empty">
<h3>No scores yet</h3>
<p>Click <strong>Recompute</strong> to build the contribution graph and run CredRank.</p>
<p style="margin-top:0.75rem;font-size:0.8rem;color:#475569">
Scores are generated from activity across Tasks, Docs, Chats, Calendar, Voting, Flows, Time, and Wallet modules.
</p>
</div>
`}`;
}
private renderContributor(timeAgo: (ts: number) => string): string {
if (this.loading) return '<div class="loading">Loading contributor...</div>';
const c = this.selectedContributor;
if (!c) return '<div class="error">Contributor not found</div>';
const breakdown = Object.entries(c.breakdown).sort(([, a], [, b]) => b - a);
return `
<div class="tab-bar">
<div class="tab" data-action="leaderboard">← Back</div>
</div>
<div class="detail-header">
<h2>${this.escapeHtml(c.label || c.did.slice(0, 20))}</h2>
<div style="font-size:0.8rem;color:#64748b;word-break:break-all">${this.escapeHtml(c.did)}</div>
<div class="detail-stats">
<div class="stat-card">
<div class="stat-value" style="color:#fbbf24">${c.cred}</div>
<div class="stat-label">Cred ⭐</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#d97706">${c.grainLifetime}</div>
<div class="stat-label">Grain 🌾</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#94a3b8">${timeAgo(c.lastActive)}</div>
<div class="stat-label">Last Active</div>
</div>
</div>
</div>
<div class="breakdown">
<h3>Contribution Breakdown</h3>
${breakdown.map(([mod, pct]) => `
<div class="bar-row">
<div class="bar-label">${MODULE_LABELS[mod] || mod}</div>
<div class="bar-track">
<div class="bar-fill" style="width:${Math.max(pct, 2)}%;background:${MODULE_COLORS[mod] || '#64748b'}">
${pct >= 10 ? MODULE_LABELS[mod] || mod : ''}
</div>
</div>
<div class="bar-pct">${pct}%</div>
</div>`).join('')}
</div>`;
}
private renderConfig(): string {
if (this.loading) return '<div class="loading">Loading config...</div>';
if (!this.config) return '<div class="error">Config not loaded</div>';
const weights = Object.entries(this.config.weights).sort(([, a], [, b]) => b.weight - a.weight);
return `
<div class="tab-bar">
<div class="tab" data-action="leaderboard">← Back</div>
<div class="tab active">Weight Configuration</div>
</div>
${this.error ? `<div class="error">${this.error}</div>` : ''}
<div style="margin-bottom:1.5rem">
<h2 style="font-size:1.2rem;margin-bottom:0.25rem">Contribution Weights</h2>
<p style="font-size:0.8rem;color:#64748b">Adjust how much each type of contribution counts toward Cred. Changes take effect on next recompute.</p>
</div>
${weights.map(([key, w]) => `
<div class="weight-row">
<div class="weight-label" title="${this.escapeHtml(w.description)}">${this.escapeHtml(w.description)}</div>
<input type="range" class="weight-slider" min="0" max="10" step="0.1" value="${w.weight}" data-weight="${key}">
<div class="weight-value" data-weight-value="${key}">${w.weight}</div>
</div>`).join('')}
<div style="margin-top:1.5rem;display:flex;gap:0.5rem">
<button class="primary" data-action="save-weights">Save Weights</button>
<button data-action="leaderboard">Cancel</button>
</div>
<div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #1e293b">
<h3 style="font-size:0.95rem;color:#94a3b8;margin-bottom:0.75rem">Grain Parameters</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.8rem;color:#64748b">
<div>Grain per epoch: <strong style="color:#e2e8f0">${this.config.grainPerEpoch}</strong></div>
<div>Epoch length: <strong style="color:#e2e8f0">${this.config.epochLengthDays} days</strong></div>
<div>Slow fraction: <strong style="color:#e2e8f0">${(this.config.slowFraction * 100).toFixed(0)}%</strong></div>
<div>Fast fraction: <strong style="color:#e2e8f0">${(this.config.fastFraction * 100).toFixed(0)}%</strong></div>
<div>Damping factor: <strong style="color:#e2e8f0">${this.config.dampingFactor}</strong></div>
<div>Lookback: <strong style="color:#e2e8f0">${this.config.lookbackDays} days</strong></div>
</div>
</div>`;
}
private escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
}
customElements.define('folk-cred-dashboard', FolkCredDashboard);