/**
* Dashboard Renderer
* Generates HTML for the enhanced plans dashboard view with glassmorphism design
*
* @module dashboard-renderer
*/
const fs = require('fs');
const path = require('path');
const {
generateTimelineStats,
generateActivityHeatmap
} = require('./plan-metadata-extractor.cjs');
/**
* Escape HTML special characters to prevent XSS
* @param {string} str - String to escape
* @returns {string} - Escaped string
*/
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Truncate text to specified length with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLen - Maximum length
* @returns {string} - Truncated text
*/
function truncate(text, maxLen = 100) {
if (!text) return '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 3).trim() + '...';
}
/**
* Get priority color class based on priority level
* @param {string} priority - Priority string (P1/P2/P3 or High/Medium/Low)
* @returns {string} - CSS class name
*/
function getPriorityColorClass(priority) {
if (!priority) return '';
const p = String(priority).toUpperCase();
if (p === 'P1' || p === 'HIGH' || p === 'CRITICAL') return 'priority-high';
if (p === 'P2' || p === 'MEDIUM' || p === 'NORMAL') return 'priority-medium';
if (p === 'P3' || p === 'LOW') return 'priority-low';
return '';
}
/**
* Format date for display
* @param {string} isoDate - ISO date string
* @returns {string} - Formatted date
*/
function formatDate(isoDate) {
if (!isoDate) return '';
const date = new Date(isoDate);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
/**
* Format relative time (e.g., "2 days ago")
* @param {string} isoDate - ISO date string
* @returns {string} - Relative time string
*/
function formatRelativeTime(isoDate) {
if (!isoDate) return '';
const date = new Date(isoDate);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
}
/**
* Get human-readable status label
* @param {string} status - Status code
* @returns {string} - Human-readable label
*/
function getStatusLabel(status) {
const labels = {
'completed': 'Completed',
'complete': 'Completed',
'in-progress': 'In Progress',
'in-review': 'In Review',
'cancelled': 'Cancelled',
'pending': 'Pending'
};
return labels[status] || 'Pending';
}
/**
* Generate SVG progress ring (kept for backward compatibility but hidden in new design)
* @param {number} progress - Progress percentage (0-100)
* @returns {string} - SVG HTML
*/
function generateProgressRing(progress) {
// Hidden in new minimal design - kept for compatibility
return '';
}
/**
* Generate simple progress bar (monochrome design)
* @param {{total: number, completed: number, inProgress: number, pending: number}} phases
* @returns {string} - Progress bar HTML
*/
function generateProgressBar(phases) {
const total = phases.total || 1;
const completedPct = ((phases.completed / total) * 100).toFixed(1);
const inProgressPct = ((phases.inProgress / total) * 100).toFixed(1);
return `
${phases.completed} of ${total} phases
`;
}
/**
* Generate status counts HTML (hidden in minimal design)
* @param {{completed: number, inProgress: number, pending: number}} phases
* @returns {string} - Status counts HTML
*/
function generateStatusCounts(phases) {
// Hidden in minimal design
return '';
}
/**
* Generate status badge HTML (simplified for monochrome design)
* @param {string} status - Status string
* @returns {string} - Status badge HTML
*/
function generateStatusBadge(status) {
const statusClass = (status || 'pending').replace(/\s+/g, '-');
// Simplified labels for minimal design
const labels = {
'completed': 'Done',
'complete': 'Done',
'in-progress': 'Active',
'pending': 'Pending'
};
const label = labels[statusClass] || 'Pending';
return `${label}`;
}
/**
* Generate meta tags HTML for plan card (duration, effort, priority, issue, tags)
* @param {Object} plan - Plan metadata
* @returns {string} - Meta tags HTML
*/
function generateCardMeta(plan) {
const metaTags = [];
// Duration tag
if (plan.durationFormatted) {
const icon = '';
metaTags.push(`${icon} ${escapeHtml(plan.durationFormatted)}`);
}
// Effort tag
if (plan.totalEffortFormatted) {
metaTags.push(` ${escapeHtml(plan.totalEffortFormatted)}`);
}
// Priority tag with color class
if (plan.priority) {
const priorityColorClass = getPriorityColorClass(plan.priority);
metaTags.push(`${escapeHtml(plan.priority)}`);
}
// Issue tag - clickable link to GitHub (uses branch to derive repo, falls back to claudekit)
if (plan.issue) {
// TODO: Make repo configurable via project settings
const issueUrl = `https://github.com/claudekit/claudekit/issues/${plan.issue}`;
metaTags.push(` #${plan.issue}`);
}
if (metaTags.length === 0) return '';
return `
${metaTags.join('')}
`;
}
/**
* Generate tags pills HTML
* @param {Array} tags - Array of tag strings
* @param {number} maxVisible - Maximum visible tags (default 3)
* @returns {string} - Tags HTML
*/
function generateTagsPills(tags, maxVisible = 3) {
if (!tags || !Array.isArray(tags) || tags.length === 0) return '';
const visibleTags = tags.slice(0, maxVisible);
const hiddenCount = tags.length - maxVisible;
let html = '
';
html += visibleTags.map(tag =>
`${escapeHtml(tag)}`
).join('');
if (hiddenCount > 0) {
html += `+${hiddenCount}`;
}
html += '
';
return html;
}
/**
* Generate HTML for a single plan card (minimal design with rich metadata)
* @param {Object} plan - Plan metadata
* @returns {string} - Card HTML
*/
function generatePlanCard(plan) {
const statusClass = (plan.status || 'pending').replace(/\s+/g, '-');
const name = escapeHtml(plan.name);
const relativeTime = formatRelativeTime(plan.lastModified);
const cardMeta = generateCardMeta(plan);
// Description section (truncated)
const descriptionHtml = plan.description
? `