feat: complete Priority 2 - Enhanced Koha Transparency Dashboard
Priority 2 Implementation: - Extract inline JavaScript to /public/js/koha-transparency.js (CSP compliant) - Add Chart.js 4.4.0 for visual allocation breakdown (doughnut chart) - Implement CSV export functionality with comprehensive transparency report - Link transparency dashboard from homepage footer (Support This Work section) - Deploy to production: https://agenticgovernance.digital/koha/transparency.html Homepage Enhancement: - Add "Support This Work" section to footer with donation links - Include Blog link in Community section Governance Framework: - Add inst_022: Automated deployment permission correction requirement - Addresses recurring permission issues (0700 directories causing 403 errors) - Mandates rsync --chmod=D755,F644 or post-deployment automation - Related to inst_020, but shifts from validation to prevention Technical Details: - Responsive design with Tailwind breakpoints - Auto-refresh metrics every 5 minutes - WCAG-compliant accessibility features - Minimal footprint: ~8.5KB JavaScript Fixes: - /public/koha/ directory permissions (755 required for nginx) - Added inst_022 to prevent future permission issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8ee0a33aa5
commit
0dccf8b660
3 changed files with 308 additions and 100 deletions
|
|
@ -477,6 +477,14 @@
|
|||
<ul class="space-y-2 text-sm">
|
||||
<li><a href="/media-inquiry.html" class="hover:text-white transition">Media Inquiries</a></li>
|
||||
<li><a href="/case-submission.html" class="hover:text-white transition">Submit Case Study</a></li>
|
||||
<li><a href="/blog.html" class="hover:text-white transition">Blog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Support This Work</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><a href="/koha.html" class="hover:text-white transition">Make a Donation</a></li>
|
||||
<li><a href="/koha/transparency.html" class="hover:text-white transition">Transparency Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
280
public/js/koha-transparency.js
Normal file
280
public/js/koha-transparency.js
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* Koha Transparency Dashboard
|
||||
* Real-time donation metrics with privacy-preserving analytics
|
||||
*/
|
||||
|
||||
// Chart instances (global for updates)
|
||||
let allocationChart = null;
|
||||
let trendChart = null;
|
||||
|
||||
/**
|
||||
* Load and display transparency metrics
|
||||
*/
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
const response = await fetch('/api/koha/transparency');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const metrics = data.data;
|
||||
|
||||
// Update stats
|
||||
updateStats(metrics);
|
||||
|
||||
// Update allocation chart
|
||||
updateAllocationChart(metrics);
|
||||
|
||||
// Update progress bars (legacy display)
|
||||
animateProgressBars();
|
||||
|
||||
// Display recent donors
|
||||
displayRecentDonors(metrics.recent_donors || []);
|
||||
|
||||
// Update last updated time
|
||||
updateLastUpdated(metrics.last_updated);
|
||||
|
||||
} else {
|
||||
throw new Error('Failed to load metrics');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading transparency metrics:', error);
|
||||
document.getElementById('recent-donors').innerHTML = `
|
||||
<div class="text-center py-8 text-red-600">
|
||||
Failed to load transparency data. Please try again later.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stats display
|
||||
*/
|
||||
function updateStats(metrics) {
|
||||
document.getElementById('total-received').textContent = `$${metrics.total_received.toFixed(2)}`;
|
||||
document.getElementById('monthly-supporters').textContent = metrics.monthly_supporters;
|
||||
document.getElementById('monthly-revenue').textContent = `$${metrics.monthly_recurring_revenue.toFixed(2)}`;
|
||||
document.getElementById('onetime-count').textContent = metrics.one_time_donations || 0;
|
||||
|
||||
// Calculate average
|
||||
const totalCount = metrics.monthly_supporters + (metrics.one_time_donations || 0);
|
||||
const avgDonation = totalCount > 0 ? metrics.total_received / totalCount : 0;
|
||||
document.getElementById('average-donation').textContent = `$${avgDonation.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update allocation pie chart
|
||||
*/
|
||||
function updateAllocationChart(metrics) {
|
||||
const ctx = document.getElementById('allocation-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
const allocation = metrics.allocation || {
|
||||
development: 0.4,
|
||||
hosting: 0.3,
|
||||
research: 0.2,
|
||||
community: 0.1
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: ['Development (40%)', 'Hosting & Infrastructure (30%)', 'Research (20%)', 'Community (10%)'],
|
||||
datasets: [{
|
||||
data: [
|
||||
allocation.development * 100,
|
||||
allocation.hosting * 100,
|
||||
allocation.research * 100,
|
||||
allocation.community * 100
|
||||
],
|
||||
backgroundColor: [
|
||||
'#3B82F6', // blue-600
|
||||
'#10B981', // green-600
|
||||
'#A855F7', // purple-600
|
||||
'#F59E0B' // orange-600
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFFFFF'
|
||||
}]
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label || '';
|
||||
const value = context.parsed;
|
||||
return `${label}: ${value.toFixed(1)}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (allocationChart) {
|
||||
allocationChart.data = data;
|
||||
allocationChart.update();
|
||||
} else {
|
||||
allocationChart = new Chart(ctx, config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate progress bars (legacy display)
|
||||
*/
|
||||
function animateProgressBars() {
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.progress-bar').forEach(bar => {
|
||||
const width = bar.getAttribute('data-width');
|
||||
bar.style.width = width + '%';
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display recent donors
|
||||
*/
|
||||
function displayRecentDonors(donors) {
|
||||
const donorsContainer = document.getElementById('recent-donors');
|
||||
const noDonorsMessage = document.getElementById('no-donors');
|
||||
|
||||
if (donors.length > 0) {
|
||||
const donorsHtml = donors.map(donor => {
|
||||
const date = new Date(donor.date);
|
||||
const dateStr = date.toLocaleDateString('en-NZ', { year: 'numeric', month: 'short' });
|
||||
const freqBadge = donor.frequency === 'monthly'
|
||||
? '<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">Monthly</span>'
|
||||
: '<span class="inline-block bg-green-100 text-green-800 text-xs px-2 py-1 rounded">One-time</span>';
|
||||
|
||||
// Format currency display
|
||||
const currency = (donor.currency || 'nzd').toUpperCase();
|
||||
const amountDisplay = `$${donor.amount.toFixed(2)} ${currency}`;
|
||||
|
||||
// Show NZD equivalent if different currency
|
||||
const nzdEquivalent = currency !== 'NZD'
|
||||
? `<div class="text-xs text-gray-500">≈ $${donor.amount_nzd.toFixed(2)} NZD</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-200 last:border-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-blue-600 font-semibold">${donor.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">${donor.name}</div>
|
||||
<div class="text-sm text-gray-500">${dateStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-semibold text-gray-900">${amountDisplay}</div>
|
||||
${nzdEquivalent}
|
||||
${freqBadge}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
donorsContainer.innerHTML = donorsHtml;
|
||||
donorsContainer.style.display = 'block';
|
||||
if (noDonorsMessage) noDonorsMessage.style.display = 'none';
|
||||
} else {
|
||||
donorsContainer.style.display = 'none';
|
||||
if (noDonorsMessage) noDonorsMessage.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last updated timestamp
|
||||
*/
|
||||
function updateLastUpdated(timestamp) {
|
||||
const lastUpdated = new Date(timestamp);
|
||||
const elem = document.getElementById('last-updated');
|
||||
if (elem) {
|
||||
elem.textContent = `Last updated: ${lastUpdated.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export transparency data as CSV
|
||||
*/
|
||||
async function exportCSV() {
|
||||
try {
|
||||
const response = await fetch('/api/koha/transparency');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error('Failed to load metrics for export');
|
||||
}
|
||||
|
||||
const metrics = data.data;
|
||||
|
||||
// Build CSV content
|
||||
let csv = 'Tractatus Koha Transparency Report\\n';
|
||||
csv += `Generated: ${new Date().toISOString()}\\n\\n`;
|
||||
|
||||
csv += 'Metric,Value\\n';
|
||||
csv += `Total Received,${metrics.total_received}\\n`;
|
||||
csv += `Monthly Supporters,${metrics.monthly_supporters}\\n`;
|
||||
csv += `One-Time Donations,${metrics.one_time_donations || 0}\\n`;
|
||||
csv += `Monthly Recurring Revenue,${metrics.monthly_recurring_revenue}\\n\\n`;
|
||||
|
||||
csv += 'Allocation Category,Percentage\\n';
|
||||
csv += `Development,${(metrics.allocation.development * 100).toFixed(1)}%\\n`;
|
||||
csv += `Hosting & Infrastructure,${(metrics.allocation.hosting * 100).toFixed(1)}%\\n`;
|
||||
csv += `Research,${(metrics.allocation.research * 100).toFixed(1)}%\\n`;
|
||||
csv += `Community,${(metrics.allocation.community * 100).toFixed(1)}%\\n\\n`;
|
||||
|
||||
if (metrics.recent_donors && metrics.recent_donors.length > 0) {
|
||||
csv += 'Recent Public Supporters\\n';
|
||||
csv += 'Name,Date,Amount,Currency,Frequency\\n';
|
||||
metrics.recent_donors.forEach(donor => {
|
||||
csv += `"${donor.name}",${donor.date},${donor.amount},${donor.currency || 'NZD'},${donor.frequency}\\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `tractatus-transparency-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting CSV:', error);
|
||||
alert('Failed to export transparency data. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load metrics
|
||||
loadMetrics();
|
||||
|
||||
// Refresh every 5 minutes
|
||||
setInterval(loadMetrics, 5 * 60 * 1000);
|
||||
|
||||
// Setup CSV export button
|
||||
const exportBtn = document.getElementById('export-csv');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', exportCSV);
|
||||
}
|
||||
});
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
<title>Transparency Dashboard | Tractatus Koha</title>
|
||||
<meta name="description" content="Full transparency on donations received and how they're allocated to support the Tractatus AI Safety Framework.">
|
||||
<link rel="stylesheet" href="/css/tailwind.css?v=1759833751">
|
||||
<!-- Chart.js for visual analytics -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.skip-link { position: absolute; left: -9999px; }
|
||||
.skip-link:focus { left: 0; z-index: 100; background: white; padding: 1rem; }
|
||||
|
|
@ -51,9 +53,17 @@
|
|||
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Full visibility into donations received and how we allocate them to support the Tractatus Framework.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-4" id="last-updated">
|
||||
Last updated: Loading...
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-4 mt-6">
|
||||
<p class="text-sm text-gray-500" id="last-updated">
|
||||
Last updated: Loading...
|
||||
</p>
|
||||
<button id="export-csv" class="text-sm bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-50 transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
|
|
@ -101,6 +111,11 @@
|
|||
<div class="bg-white shadow rounded-lg p-8 mb-12">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">How Donations Are Allocated</h2>
|
||||
|
||||
<!-- Allocation Chart -->
|
||||
<div class="mb-8 max-w-md mx-auto">
|
||||
<canvas id="allocation-chart" aria-label="Donation allocation pie chart" role="img"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Development: 40% -->
|
||||
<div>
|
||||
|
|
@ -210,103 +225,8 @@
|
|||
<!-- Coming Soon Overlay (remove when Stripe is configured) -->
|
||||
<script src="/js/components/coming-soon-overlay.js"></script>
|
||||
|
||||
<script>
|
||||
// Load transparency metrics
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
const response = await fetch('/api/koha/transparency');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const metrics = data.data;
|
||||
|
||||
// Update stats
|
||||
document.getElementById('total-received').textContent = `$${metrics.total_received.toFixed(2)}`;
|
||||
document.getElementById('monthly-supporters').textContent = metrics.monthly_supporters;
|
||||
document.getElementById('monthly-revenue').textContent = `$${metrics.monthly_recurring_revenue.toFixed(2)}`;
|
||||
document.getElementById('onetime-count').textContent = metrics.one_time_donations || 0;
|
||||
|
||||
// Calculate average
|
||||
const totalCount = metrics.monthly_supporters + (metrics.one_time_donations || 0);
|
||||
const avgDonation = totalCount > 0 ? metrics.total_received / totalCount : 0;
|
||||
document.getElementById('average-donation').textContent = `$${avgDonation.toFixed(2)}`;
|
||||
|
||||
// Update last updated time
|
||||
const lastUpdated = new Date(metrics.last_updated);
|
||||
document.getElementById('last-updated').textContent = `Last updated: ${lastUpdated.toLocaleString()}`;
|
||||
|
||||
// Animate progress bars
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.progress-bar').forEach(bar => {
|
||||
const width = bar.getAttribute('data-width');
|
||||
bar.style.width = width + '%';
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// Display recent donors
|
||||
if (metrics.recent_donors && metrics.recent_donors.length > 0) {
|
||||
const donorsHtml = metrics.recent_donors.map(donor => {
|
||||
const date = new Date(donor.date);
|
||||
const dateStr = date.toLocaleDateString('en-NZ', { year: 'numeric', month: 'short' });
|
||||
const freqBadge = donor.frequency === 'monthly'
|
||||
? '<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">Monthly</span>'
|
||||
: '<span class="inline-block bg-green-100 text-green-800 text-xs px-2 py-1 rounded">One-time</span>';
|
||||
|
||||
// Format currency display
|
||||
const currency = (donor.currency || 'nzd').toUpperCase();
|
||||
const amountDisplay = `$${donor.amount.toFixed(2)} ${currency}`;
|
||||
|
||||
// Show NZD equivalent if different currency
|
||||
const nzdEquivalent = currency !== 'NZD'
|
||||
? `<div class="text-xs text-gray-500">≈ $${donor.amount_nzd.toFixed(2)} NZD</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-200 last:border-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-blue-600 font-semibold">${donor.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">${donor.name}</div>
|
||||
<div class="text-sm text-gray-500">${dateStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-semibold text-gray-900">${amountDisplay}</div>
|
||||
${nzdEquivalent}
|
||||
${freqBadge}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('recent-donors').innerHTML = donorsHtml;
|
||||
} else {
|
||||
document.getElementById('recent-donors').style.display = 'none';
|
||||
document.getElementById('no-donors').style.display = 'block';
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error('Failed to load metrics');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading transparency metrics:', error);
|
||||
document.getElementById('recent-donors').innerHTML = `
|
||||
<div class="text-center py-8 text-red-600">
|
||||
Failed to load transparency data. Please try again later.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load metrics on page load
|
||||
loadMetrics();
|
||||
|
||||
// Refresh every 5 minutes
|
||||
setInterval(loadMetrics, 5 * 60 * 1000);
|
||||
</script>
|
||||
<!-- Transparency Dashboard JavaScript -->
|
||||
<script src="/js/koha-transparency.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue