/** * — 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; epochScores: Record; lastActive: number; } interface WeightConfig { type: string; weight: number; description: string; } interface ConfigResponse { weights: Record; grainPerEpoch: number; epochLengthDays: number; slowFraction: number; fastFraction: number; dampingFactor: number; lookbackDays: number; enabled: boolean; lastEpochAt: number; } const MODULE_COLORS: Record = { rtasks: '#3b82f6', rdocs: '#8b5cf6', rchats: '#06b6d4', rcal: '#f59e0b', rvote: '#ec4899', rflows: '#22c55e', rtime: '#a78bfa', rwallet: '#d97706', }; const MODULE_LABELS: Record = { 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 = `
${content}
`; // 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 '
Loading scores...
'; const hasScores = this.scores.length > 0; return `

Contribution Recognition

${this.error ? `
${this.error}
` : ''}
${hasScores ? `Last computed: ${timeAgo(this.computedAt)} · ${this.scores.length} contributors · ${this.totalCred} total cred` : ''}
${hasScores ? ` ${this.scores.map(s => ` `).join('')}
# Contributor Cred ⭐ Grain 🌾 Top Module Active
${s.rank} ${this.escapeHtml(s.label || s.did.slice(0, 20))} ${s.cred} ${s.grainLifetime} ${s.topModule ? `${MODULE_LABELS[s.topModule] || s.topModule}` : '—'} ${timeAgo(s.lastActive)}
` : `

No scores yet

Click Recompute to build the contribution graph and run CredRank.

Scores are generated from activity across Tasks, Docs, Chats, Calendar, Voting, Flows, Time, and Wallet modules.

`}`; } private renderContributor(timeAgo: (ts: number) => string): string { if (this.loading) return '
Loading contributor...
'; const c = this.selectedContributor; if (!c) return '
Contributor not found
'; const breakdown = Object.entries(c.breakdown).sort(([, a], [, b]) => b - a); return `
← Back

${this.escapeHtml(c.label || c.did.slice(0, 20))}

${this.escapeHtml(c.did)}
${c.cred}
Cred ⭐
${c.grainLifetime}
Grain 🌾
${timeAgo(c.lastActive)}
Last Active

Contribution Breakdown

${breakdown.map(([mod, pct]) => `
${MODULE_LABELS[mod] || mod}
${pct >= 10 ? MODULE_LABELS[mod] || mod : ''}
${pct}%
`).join('')}
`; } private renderConfig(): string { if (this.loading) return '
Loading config...
'; if (!this.config) return '
Config not loaded
'; const weights = Object.entries(this.config.weights).sort(([, a], [, b]) => b.weight - a.weight); return `
← Back
Weight Configuration
${this.error ? `
${this.error}
` : ''}

Contribution Weights

Adjust how much each type of contribution counts toward Cred. Changes take effect on next recompute.

${weights.map(([key, w]) => `
${this.escapeHtml(w.description)}
${w.weight}
`).join('')}

Grain Parameters

Grain per epoch: ${this.config.grainPerEpoch}
Epoch length: ${this.config.epochLengthDays} days
Slow fraction: ${(this.config.slowFraction * 100).toFixed(0)}%
Fast fraction: ${(this.config.fastFraction * 100).toFixed(0)}%
Damping factor: ${this.config.dampingFactor}
Lookback: ${this.config.lookbackDays} days
`; } private escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } } customElements.define('folk-cred-dashboard', FolkCredDashboard);