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 ff3f1abed1
commit 8b9bb89797
4 changed files with 378 additions and 106 deletions

View file

@ -1,6 +1,6 @@
{
"version": "1.0",
"last_updated": "2025-10-07T19:30:00Z",
"last_updated": "2025-10-11T04:05:00Z",
"description": "Persistent instruction database for Tractatus framework governance",
"instructions": [
{
@ -363,20 +363,84 @@
},
"active": true,
"notes": "IDENTIFIED 2025-10-10 - User observed frequent compaction events despite ContextPressureMonitor reporting 'NORMAL' (6.7%) pressure at 50k token checkpoint. Actual context consumption much higher due to tool results (reading instruction-history.json twice = 12k tokens, concurrent-session doc = large, multiple bash outputs). Current monitor only accurately tracks response generation, not total context window usage. This gap causes unexpected compactions and poor handoff timing. API Memory may reduce impact but won't eliminate root cause."
},
{
"id": "inst_020",
"text": "Web application deployments MUST ensure correct file permissions before going live. All public-facing directories need 755 permissions (world-readable+executable), static files (HTML/CSS/JS/images) need 644 permissions (world-readable). Deployment scripts should verify nginx/apache can access all public paths. Add automated permission validation to deployment workflows to prevent 403 Forbidden errors.",
"timestamp": "2025-10-11T02:20:00Z",
"quadrant": "SYSTEM",
"persistence": "HIGH",
"temporal_scope": "PROJECT",
"verification_required": "MANDATORY",
"explicitness": 1.0,
"source": "system",
"session_id": "2025-10-07-001",
"parameters": {
"directory_permissions": "755",
"file_permissions": "644",
"directories_requiring_755": ["/public", "/public/admin", "/public/js", "/public/js/admin", "/public/css", "/public/images", "/public/downloads"],
"deployment_check": "stat -c '%a %n' /path/to/public/* | grep -v '755\\|644'",
"prevention": "Add to deployment scripts or CI/CD pipeline"
},
"active": true,
"notes": "DEPLOYMENT ISSUE 2025-10-11 - Priority 1 blog deployment: /public/admin/ directory had 0700 permissions (owner-only), causing nginx to return 403 Forbidden for all admin pages (/admin/login.html, /admin/project-manager.html, etc.). rsync preserved restrictive local permissions during deployment. Fixed with 'chmod 755 /public/admin && chmod 644 /public/admin/*.html'. This is preventable with automated permission validation in deployment workflow."
},
{
"id": "inst_021",
"text": "When implementing new features with dedicated models/controllers/routes, document the API-Model-Controller relationship clearly. Controller file headers should include endpoint examples, route files should document the model they operate on, and create API reference documentation in docs/api/. Update the API root endpoint (/api) with new route listings. This prevents confusion when multiple overlapping concepts exist (e.g., Projects for governance vs Blog for content).",
"timestamp": "2025-10-11T02:25:00Z",
"quadrant": "OPERATIONAL",
"persistence": "HIGH",
"temporal_scope": "PROJECT",
"verification_required": "REQUIRED",
"explicitness": 0.95,
"source": "system",
"session_id": "2025-10-07-001",
"parameters": {
"documentation_locations": ["controller file header", "route file comments", "docs/api/ directory", "/api root endpoint"],
"controller_header_template": "Model: X.model.js | Routes: /api/path | Endpoints: GET /api/path, POST /api/path",
"route_file_comments": "Document model, validation requirements, authentication, examples",
"api_docs_format": "Markdown with endpoint details, request/response examples, error codes",
"update_api_root": "Add new routes to src/routes/index.js root handler"
},
"active": true,
"notes": "DEVELOPMENT CONFUSION 2025-10-11 - Priority 1 blog testing: Initially tried using /api/admin/projects for blog posts instead of /api/blog, because both 'Projects' (governance system) and 'Blog' (content system) deal with project-like entities. BlogPost.model.js exists separately from Project.model.js, with dedicated blog.controller.js and blog.routes.js, but this wasn't immediately obvious. Clear Model-Controller-Route documentation would have prevented this 10-minute detour. The API confusion delayed testing and could confuse future developers."
},
{
"id": "inst_022",
"text": "ALL deployment scripts (rsync, scp, git pull) MUST include automated post-deployment permission correction as a standard step, not a reactive fix after errors. Use '--chmod=D755,F644' with rsync or equivalent automated permission setting for other tools. Directory creation during deployment MUST explicitly set 755 (directories) and 644 (files) permissions.",
"timestamp": "2025-10-11T04:05:00Z",
"quadrant": "SYSTEM",
"persistence": "HIGH",
"temporal_scope": "PERMANENT",
"verification_required": "MANDATORY",
"explicitness": 1.0,
"source": "system",
"session_id": "2025-10-11-priority-2-koha",
"parameters": {
"rsync_chmod_flag": "--chmod=D755,F644",
"rsync_example": "rsync -avz --chmod=D755,F644 -e 'ssh -i key' local/ remote:/path/",
"post_deploy_verification": "ssh remote 'find /var/www/tractatus/public -type d -exec chmod 755 {} + && find /var/www/tractatus/public -type f -name \"*.html\" -o -name \"*.js\" -o -name \"*.css\" -exec chmod 644 {} +'",
"deployment_script_requirement": "scripts/deploy-full-project-SAFE.sh and any ad-hoc rsync commands MUST use --chmod flag or include post-deployment permission fix as standard final step",
"applies_to": ["rsync", "scp", "git pull", "docker volumes", "manual copies"]
},
"related_instructions": ["inst_020"],
"active": true,
"notes": "RECURRING DEPLOYMENT ISSUE 2025-10-11 - Despite inst_020 requiring permission validation, /public/koha/ directory had 0700 permissions (same pattern as /public/admin/ in previous session). Root cause: rsync creates directories with restrictive umask defaults, and inst_020 focuses on reactive validation rather than proactive automation. This shifts from 'MUST ensure permissions' (principle) to 'USE --chmod flag or automated fix' (automation requirement). Prevents manual permission fixing after discovering 403 errors."
}
],
"stats": {
"total_instructions": 19,
"active_instructions": 19,
"total_instructions": 22,
"active_instructions": 22,
"by_quadrant": {
"STRATEGIC": 6,
"OPERATIONAL": 5,
"OPERATIONAL": 6,
"TACTICAL": 1,
"SYSTEM": 7,
"SYSTEM": 9,
"STOCHASTIC": 0
},
"by_persistence": {
"HIGH": 17,
"HIGH": 20,
"MEDIUM": 2,
"LOW": 0,
"VARIABLE": 0

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>