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:
TheFlow 2025-10-11 17:14:34 +13:00
parent 8ee0a33aa5
commit 0dccf8b660
3 changed files with 308 additions and 100 deletions

View file

@ -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>

View 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);
}
});

View file

@ -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>