Files
english/.opencode/skills/plans-kanban/assets/dashboard.js
2026-04-12 01:06:31 +07:00

597 lines
17 KiB
JavaScript

/**
* Dashboard Controls
* Client-side sorting, filtering, and search for plans dashboard
*/
(function() {
'use strict';
// State
const state = {
sort: 'date-desc',
filter: 'all',
search: '',
view: 'kanban' // 'kanban' or 'grid'
};
// Status column config
const STATUS_COLUMNS = [
{ id: 'pending', label: 'Pending', color: 'pending' },
{ id: 'in-progress', label: 'In Progress', color: 'in-progress' },
{ id: 'in-review', label: 'In Review', color: 'in-review' },
{ id: 'completed', label: 'Done', color: 'completed' },
{ id: 'cancelled', label: 'Cancelled', color: 'cancelled' }
];
// Elements
let allPlans = [];
let grid = null;
let kanbanBoard = null;
let resultCount = null;
let emptyState = null;
let srAnnounce = null;
/**
* Initialize dashboard
*/
function init() {
allPlans = window.__plans || [];
grid = document.querySelector('.plans-grid');
kanbanBoard = document.querySelector('.kanban-board');
resultCount = document.querySelector('.result-count');
emptyState = document.querySelector('.empty-state');
srAnnounce = document.getElementById('sr-announce');
// Mark as loaded
document.body.classList.add('plans-loaded');
// Show empty state if no plans
if (!allPlans.length) {
if (emptyState) emptyState.hidden = false;
return;
}
// Parse URL state
parseURL();
// Bind events
bindEvents();
// Initial render
applyFiltersAndSort();
// Setup keyboard navigation
setupKeyboardNav();
// Setup theme toggle
setupThemeToggle();
// Setup view toggle
setupViewToggle();
}
/**
* Bind event listeners
*/
function bindEvents() {
// Search input with debounce
const searchInput = document.getElementById('plan-search');
if (searchInput) {
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
state.search = e.target.value.toLowerCase().trim();
applyFiltersAndSort();
}, 300);
});
// Clear search on Escape
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
searchInput.value = '';
state.search = '';
applyFiltersAndSort();
}
});
}
// Sort select
const sortSelect = document.getElementById('sort-select');
if (sortSelect) {
sortSelect.addEventListener('change', (e) => {
state.sort = e.target.value;
applyFiltersAndSort();
});
}
// Filter pills
document.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
// Update active state
document.querySelectorAll('.filter-pill').forEach(p => {
p.classList.remove('active');
p.setAttribute('aria-pressed', 'false');
});
pill.classList.add('active');
pill.setAttribute('aria-pressed', 'true');
state.filter = pill.dataset.filter;
applyFiltersAndSort();
});
});
// Card click to navigate
grid?.addEventListener('click', (e) => {
const card = e.target.closest('.plan-card');
if (card && !e.target.closest('.view-btn')) {
const link = card.querySelector('.view-btn');
if (link) link.click();
}
});
}
/**
* Apply filters and sorting
*/
function applyFiltersAndSort() {
let filtered = allPlans.slice();
// Filter by status
if (state.filter !== 'all') {
filtered = filtered.filter(p => p.status === state.filter);
}
// Filter by search
if (state.search) {
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(state.search) ||
p.id.toLowerCase().includes(state.search)
);
}
// Sort
filtered.sort((a, b) => {
switch (state.sort) {
case 'date-desc':
return new Date(b.lastModified) - new Date(a.lastModified);
case 'date-asc':
return new Date(a.lastModified) - new Date(b.lastModified);
case 'name-asc':
return a.name.localeCompare(b.name);
case 'name-desc':
return b.name.localeCompare(a.name);
case 'progress-desc':
return b.progress - a.progress;
case 'progress-asc':
return a.progress - b.progress;
default:
return 0;
}
});
renderGrid(filtered);
// Also update kanban if in kanban view
if (state.view === 'kanban') {
renderKanbanBoard(allPlans); // Kanban uses all plans grouped by status
}
updateURL();
announce(`Showing ${filtered.length} plan${filtered.length !== 1 ? 's' : ''}`);
}
/**
* Render grid with filtered plans
*/
function renderGrid(plans) {
const visibleIds = new Set(plans.map(p => p.id));
// Hide/show cards and set order
document.querySelectorAll('.plan-card').forEach(card => {
const id = card.dataset.id;
const isVisible = visibleIds.has(id);
card.style.display = isVisible ? '' : 'none';
if (isVisible) {
const index = plans.findIndex(p => p.id === id);
card.style.order = index;
}
});
// Update count
if (resultCount) {
resultCount.innerHTML = `Showing <strong>${plans.length}</strong> plan${plans.length !== 1 ? 's' : ''}`;
}
// Show/hide empty state
if (emptyState) {
emptyState.hidden = plans.length > 0;
}
}
/**
* Parse URL parameters
*/
function parseURL() {
const params = new URLSearchParams(window.location.search);
if (params.has('sort')) {
state.sort = params.get('sort');
}
if (params.has('filter')) {
state.filter = params.get('filter');
}
if (params.has('q')) {
state.search = params.get('q');
}
// Update controls to match state
const sortSelect = document.getElementById('sort-select');
if (sortSelect) sortSelect.value = state.sort;
const searchInput = document.getElementById('plan-search');
if (searchInput) searchInput.value = state.search;
document.querySelectorAll('.filter-pill').forEach(p => {
const isActive = p.dataset.filter === state.filter;
p.classList.toggle('active', isActive);
p.setAttribute('aria-pressed', String(isActive));
});
}
/**
* Update URL with current state
*/
function updateURL() {
const params = new URLSearchParams();
if (state.sort !== 'date-desc') params.set('sort', state.sort);
if (state.filter !== 'all') params.set('filter', state.filter);
if (state.search) params.set('q', state.search);
const url = params.toString()
? `${window.location.pathname}?${params}`
: window.location.pathname;
history.replaceState(null, '', url);
}
/**
* Announce message to screen readers
*/
function announce(message) {
if (!srAnnounce) return;
srAnnounce.textContent = '';
// Force reflow
void srAnnounce.offsetHeight;
srAnnounce.textContent = message;
}
/**
* Setup keyboard navigation for cards
*/
function setupKeyboardNav() {
grid?.addEventListener('keydown', (e) => {
const cards = [...document.querySelectorAll('.plan-card:not([style*="display: none"])')];
const current = document.activeElement;
const index = cards.indexOf(current);
if (index === -1) return;
let next;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
next = cards[index + 1] || cards[0];
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
next = cards[index - 1] || cards[cards.length - 1];
break;
case 'Enter':
case ' ':
e.preventDefault();
const link = current.querySelector('.view-btn');
if (link) link.click();
break;
case 'Home':
e.preventDefault();
next = cards[0];
break;
case 'End':
e.preventDefault();
next = cards[cards.length - 1];
break;
}
if (next) next.focus();
});
}
/**
* Setup theme toggle
*/
function setupThemeToggle() {
const toggle = document.getElementById('theme-toggle');
if (!toggle) return;
// Check saved preference
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', initialTheme);
toggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
announce(`Theme changed to ${next} mode`);
});
}
/**
* Setup view toggle (Kanban/Grid)
*/
function setupViewToggle() {
const toggleBtns = document.querySelectorAll('.view-toggle-btn');
if (!toggleBtns.length) return;
// Restore saved view preference
const savedView = localStorage.getItem('dashboard-view') || 'kanban';
state.view = savedView;
updateViewMode();
toggleBtns.forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
if (view === state.view) return;
state.view = view;
localStorage.setItem('dashboard-view', view);
updateViewMode();
announce(`Switched to ${view} view`);
});
});
}
/**
* Update view mode (toggle between kanban and grid)
*/
function updateViewMode() {
const body = document.body;
// Update toggle buttons
document.querySelectorAll('.view-toggle-btn').forEach(btn => {
const isActive = btn.dataset.view === state.view;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
// Toggle view classes
if (state.view === 'grid') {
body.classList.add('view-mode-grid');
} else {
body.classList.remove('view-mode-grid');
}
// Re-render kanban when switching to kanban view
if (state.view === 'kanban') {
renderKanbanBoard(getFilteredPlans());
}
}
/**
* Get filtered and sorted plans
*/
function getFilteredPlans() {
let filtered = allPlans.slice();
// Filter by status (only for grid view, kanban shows all columns)
if (state.filter !== 'all' && state.view === 'grid') {
filtered = filtered.filter(p => p.status === state.filter);
}
// Filter by search
if (state.search) {
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(state.search) ||
p.id.toLowerCase().includes(state.search)
);
}
// Sort
filtered.sort((a, b) => {
switch (state.sort) {
case 'date-desc':
return new Date(b.lastModified) - new Date(a.lastModified);
case 'date-asc':
return new Date(a.lastModified) - new Date(b.lastModified);
case 'name-asc':
return a.name.localeCompare(b.name);
case 'name-desc':
return b.name.localeCompare(a.name);
case 'progress-desc':
return b.progress - a.progress;
case 'progress-asc':
return a.progress - b.progress;
default:
return 0;
}
});
return filtered;
}
/**
* Render kanban board with plans grouped by status
*/
function renderKanbanBoard(plans) {
if (!kanbanBoard) return;
// Group plans by status
const grouped = {};
STATUS_COLUMNS.forEach(col => {
grouped[col.id] = [];
});
// Apply search filter for kanban
let filteredPlans = plans;
if (state.search) {
filteredPlans = plans.filter(p =>
p.name.toLowerCase().includes(state.search) ||
p.id.toLowerCase().includes(state.search)
);
}
filteredPlans.forEach(plan => {
const status = plan.status || 'pending';
if (grouped[status]) {
grouped[status].push(plan);
} else {
grouped['pending'].push(plan);
}
});
// Generate column HTML
const columnsHtml = STATUS_COLUMNS.map(col => {
const columnPlans = grouped[col.id];
const cardsHtml = columnPlans.length > 0
? columnPlans.map(plan => renderKanbanCard(plan)).join('')
: `<div class="kanban-empty">
<svg class="kanban-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M9 9h6M9 13h6M9 17h4"/>
</svg>
<span>No plans</span>
</div>`;
return `
<div class="kanban-column" data-status="${col.id}">
<div class="kanban-column-header">
<div class="kanban-column-title">
<span class="kanban-status-dot ${col.color}"></span>
<span>${col.label}</span>
</div>
<span class="kanban-column-count">${columnPlans.length}</span>
</div>
<div class="kanban-cards">
${cardsHtml}
</div>
</div>
`;
}).join('');
kanbanBoard.innerHTML = columnsHtml;
}
/**
* Render a single kanban card (enhanced with details)
*/
function renderKanbanCard(plan) {
const progressPct = Math.round(plan.progress || 0);
const dateStr = formatDate(plan.lastModified);
// Priority badge
let priorityHtml = '';
if (plan.priority) {
const p = String(plan.priority).toUpperCase();
let priorityClass = '';
if (p === 'P1' || p === 'HIGH' || p === 'CRITICAL') priorityClass = 'priority-high';
else if (p === 'P2' || p === 'MEDIUM' || p === 'NORMAL') priorityClass = 'priority-medium';
else if (p === 'P3' || p === 'LOW') priorityClass = 'priority-low';
if (priorityClass) {
priorityHtml = `<span class="kanban-card-priority ${priorityClass}">${escapeHtml(plan.priority)}</span>`;
}
}
// Description (truncated)
let descriptionHtml = '';
if (plan.description) {
const desc = plan.description.length > 80 ? plan.description.slice(0, 77) + '...' : plan.description;
descriptionHtml = `<p class="kanban-card-description">${escapeHtml(desc)}</p>`;
}
// Tags (max 3 visible)
let tagsHtml = '';
if (plan.tags && Array.isArray(plan.tags) && plan.tags.length > 0) {
const visibleTags = plan.tags.slice(0, 3);
const hiddenCount = plan.tags.length - 3;
tagsHtml = '<div class="kanban-card-tags">';
tagsHtml += visibleTags.map(tag => `<span class="kanban-card-tag">${escapeHtml(tag)}</span>`).join('');
if (hiddenCount > 0) {
tagsHtml += `<span class="kanban-card-tag tag-more">+${hiddenCount}</span>`;
}
tagsHtml += '</div>';
}
// Footer with effort and phases
let footerHtml = '';
const effortHtml = plan.totalEffortFormatted
? `<span class="kanban-card-effort"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>${escapeHtml(plan.totalEffortFormatted)}</span>`
: '';
const phasesHtml = plan.phasesTotal
? `<span class="kanban-card-phases">${plan.phasesTotal} phases</span>`
: '';
if (effortHtml || phasesHtml) {
footerHtml = `<div class="kanban-card-footer">${effortHtml}${phasesHtml}</div>`;
}
return `
<a href="/view?file=${encodeURIComponent(plan.path)}" class="kanban-card" data-id="${plan.id}">
<div class="kanban-card-header">
<h4 class="kanban-card-title">${escapeHtml(plan.name)}</h4>
${priorityHtml}
</div>
${descriptionHtml}
<div class="kanban-card-meta">
<div class="kanban-card-progress">
<div class="kanban-card-progress-bar">
<div class="kanban-card-progress-fill" style="width: ${progressPct}%"></div>
</div>
<span>${progressPct}%</span>
</div>
<span class="kanban-card-date">${dateStr}</span>
</div>
${tagsHtml}
${footerHtml}
</a>
`;
}
/**
* Format date for display
*/
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();