459 lines
16 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-cred-dashboard', FolkCredDashboard);
|