Replace project filter buttons with multi-select dropdown

Projects now appear as a dropdown with checkboxes instead of
horizontal buttons. Supports All Projects, Clear All, and
individual project toggle filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 07:11:34 -07:00
parent 19f669f7d1
commit f30d3d603c
2 changed files with 437 additions and 70 deletions

View File

@ -38,7 +38,9 @@ function App() {
const [connected, setConnected] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [filter, setFilter] = useState<string>("");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [selectedProjects, setSelectedProjects] = useState<Set<string> | null>(null); // null = all
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<AggregatedTask | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
@ -205,13 +207,52 @@ function App() {
};
}, [connectWebSocket]);
// Close dropdown on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
const isProjectSelected = (name: string) => selectedProjects === null || selectedProjects.has(name);
const toggleProject = (name: string) => {
const activeNames = projects.filter((p) => tasks.some((t) => t.projectName === p.name)).map((p) => p.name);
if (selectedProjects === null) {
const next = new Set(activeNames);
next.delete(name);
setSelectedProjects(next);
} else if (selectedProjects.has(name)) {
const next = new Set(selectedProjects);
next.delete(name);
setSelectedProjects(next);
} else {
const next = new Set(selectedProjects);
next.add(name);
setSelectedProjects(next.size === activeNames.length ? null : next);
}
};
const activeProjects = projects.filter((p) => tasks.some((t) => t.projectName === p.name));
const dropdownLabel = (() => {
if (selectedProjects === null) return "All Projects";
if (selectedProjects.size === 0) return "No Projects";
if (selectedProjects.size === 1) return [...selectedProjects][0];
return `${selectedProjects.size} Projects`;
})();
// Group tasks by status
const statuses = ["To Do", "In Progress", "Done", "Won't Do"];
const tasksByStatus = statuses.reduce(
(acc, status) => {
acc[status] = tasks.filter((t) => {
const statusMatch = t.status.toLowerCase() === status.toLowerCase();
const projectMatch = !selectedProject || t.projectName === selectedProject;
const projectMatch = selectedProjects === null || selectedProjects.has(t.projectName);
const filterMatch =
!filter ||
t.title.toLowerCase().includes(filter.toLowerCase()) ||
@ -348,46 +389,150 @@ function App() {
minWidth: "200px",
}}
/>
<button
onClick={() => setSelectedProject(null)}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: selectedProject === null ? "2px solid #3b82f6" : "1px solid #334155",
backgroundColor: selectedProject === null ? "#1e40af" : "#0f172a",
color: "#e2e8f0",
cursor: "pointer",
}}
>
All Projects
</button>
{projects.map((project) => (
<button
key={project.path}
onClick={() => setSelectedProject(project.name)}
<div ref={dropdownRef} style={{ position: "relative" }}>
<div
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(!dropdownOpen);
}}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: selectedProject === project.name ? `2px solid ${project.color}` : "1px solid #334155",
backgroundColor: selectedProject === project.name ? project.color + "33" : "#0f172a",
border: "1px solid #334155",
backgroundColor: "#0f172a",
color: "#e2e8f0",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
minWidth: "180px",
userSelect: "none",
}}
>
<span
<span style={{ display: "flex", gap: "0.25rem", alignItems: "center" }}>
{selectedProjects !== null && selectedProjects.size > 0 && selectedProjects.size <= 5
? [...selectedProjects].map((name) => {
const proj = projects.find((p) => p.name === name);
return proj ? (
<span
key={name}
style={{
width: selectedProjects.size === 1 ? "0.75rem" : "0.5rem",
height: selectedProjects.size === 1 ? "0.75rem" : "0.5rem",
borderRadius: "50%",
backgroundColor: proj.color,
display: "inline-block",
}}
/>
) : null;
})
: null}
</span>
<span>{dropdownLabel}</span>
<span style={{ marginLeft: "auto", fontSize: "0.625rem", transition: "transform 0.2s", transform: dropdownOpen ? "rotate(180deg)" : "none" }}>
</span>
</div>
{dropdownOpen && (
<div
style={{
width: "0.75rem",
height: "0.75rem",
borderRadius: "50%",
backgroundColor: project.color,
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
minWidth: "220px",
background: "#1e293b",
border: "1px solid #334155",
borderRadius: "0.5rem",
boxShadow: "0 10px 25px rgba(0,0,0,0.4)",
zIndex: 100,
overflow: "hidden",
}}
/>
{project.name}
</button>
))}
>
<div style={{ display: "flex", borderBottom: "1px solid #334155" }}>
<button
onClick={() => setSelectedProjects(null)}
style={{
flex: 1,
padding: "0.5rem",
background: "none",
border: "none",
borderRight: "1px solid #334155",
color: "#94a3b8",
fontSize: "0.75rem",
cursor: "pointer",
}}
>
All Projects
</button>
<button
onClick={() => setSelectedProjects(new Set())}
style={{
flex: 1,
padding: "0.5rem",
background: "none",
border: "none",
color: "#94a3b8",
fontSize: "0.75rem",
cursor: "pointer",
}}
>
Clear All
</button>
</div>
{activeProjects.map((project) => {
const selected = isProjectSelected(project.name);
return (
<div
key={project.path}
onClick={() => toggleProject(project.name)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0.75rem",
cursor: "pointer",
fontSize: "0.875rem",
background: "transparent",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "#334155")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
<span
style={{
width: "1rem",
height: "1rem",
border: selected ? "1.5px solid #3b82f6" : "1.5px solid #475569",
borderRadius: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: selected ? "#3b82f6" : "transparent",
flexShrink: 0,
}}
>
{selected && (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 5L4 7L8 3" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
<span
style={{
width: "0.75rem",
height: "0.75rem",
borderRadius: "50%",
backgroundColor: project.color,
flexShrink: 0,
}}
/>
{project.name}
</div>
);
})}
</div>
)}
</div>
</div>
{/* View Content */}
@ -486,7 +631,7 @@ function App() {
<DependencyGraph
tasks={tasks}
projects={projects}
selectedProject={selectedProject}
selectedProjects={selectedProjects}
filter={filter}
showCompleted={showCompleted}
/>
@ -842,7 +987,7 @@ function TaskCard({ task, onArchive, onDelete, onUpdate, statuses, onDragStart,
interface DependencyGraphProps {
tasks: AggregatedTask[];
projects: Project[];
selectedProject: string | null;
selectedProjects: Set<string> | null;
filter: string;
showCompleted: boolean;
}
@ -873,7 +1018,7 @@ function isCompleted(status: string): boolean {
return lower.includes("done") || lower.includes("complete");
}
function DependencyGraph({ tasks, projects, selectedProject, filter, showCompleted }: DependencyGraphProps) {
function DependencyGraph({ tasks, projects, selectedProjects, filter, showCompleted }: DependencyGraphProps) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
@ -882,11 +1027,11 @@ function DependencyGraph({ tasks, projects, selectedProject, filter, showComplet
const filteredTasks = useMemo(() => {
return tasks.filter((t) => {
if (!showCompleted && isCompleted(t.status)) return false;
if (selectedProject && t.projectName !== selectedProject) return false;
if (selectedProjects !== null && !selectedProjects.has(t.projectName)) return false;
if (filter && !t.title.toLowerCase().includes(filter.toLowerCase())) return false;
return true;
});
}, [tasks, showCompleted, selectedProject, filter]);
}, [tasks, showCompleted, selectedProjects, filter]);
// Build graph data - note: aggregated tasks don't have dependencies in YAML
// So we'll just show all tasks as nodes without edges for now

View File

@ -48,7 +48,34 @@
color: #e2e8f0;
min-width: 200px;
}
.project-btn, .action-btn {
.action-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #22c55e;
border-color: #16a34a;
color: #e2e8f0;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
}
.action-btn:hover { background: #16a34a; }
.project-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
/* Project Dropdown Filter */
.project-dropdown {
position: relative;
}
.project-dropdown-trigger {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #334155;
@ -59,14 +86,97 @@
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
min-width: 180px;
user-select: none;
}
.project-btn.active { border-width: 2px; border-color: #3b82f6; background: #1e40af; }
.action-btn { background: #22c55e; border-color: #16a34a; font-weight: 500; }
.action-btn:hover { background: #16a34a; }
.project-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
.project-dropdown-trigger:hover {
border-color: #475569;
background: #1e293b;
}
.project-dropdown-trigger .arrow {
margin-left: auto;
font-size: 0.625rem;
transition: transform 0.2s;
}
.project-dropdown.open .arrow {
transform: rotate(180deg);
}
.project-dropdown-trigger .selected-dots {
display: flex;
gap: 0.25rem;
align-items: center;
}
.project-dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 220px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0,0,0,0.4);
z-index: 100;
overflow: hidden;
}
.project-dropdown.open .project-dropdown-menu {
display: block;
}
.project-dropdown-actions {
display: flex;
border-bottom: 1px solid #334155;
}
.project-dropdown-actions button {
flex: 1;
padding: 0.5rem;
background: none;
border: none;
color: #94a3b8;
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.project-dropdown-actions button:hover {
background: #334155;
color: #e2e8f0;
}
.project-dropdown-actions button:first-child {
border-right: 1px solid #334155;
}
.project-dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
font-size: 0.875rem;
}
.project-dropdown-item:hover {
background: #334155;
}
.project-dropdown-item .checkbox {
width: 1rem;
height: 1rem;
border: 1.5px solid #475569;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s, border-color 0.15s;
}
.project-dropdown-item.selected .checkbox {
background: #3b82f6;
border-color: #3b82f6;
}
.project-dropdown-item.selected .checkbox::after {
content: '';
width: 0.35rem;
height: 0.6rem;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translateY(-1px);
}
.board {
padding: 2rem;
@ -516,7 +626,20 @@
<div class="filter-bar" id="filter-bar">
<input type="text" id="filter" class="filter-input" placeholder="Filter tasks...">
<button class="project-btn active" id="all-projects">All Projects</button>
<div class="project-dropdown" id="project-dropdown">
<div class="project-dropdown-trigger" id="project-dropdown-trigger">
<span class="selected-dots" id="selected-dots"></span>
<span id="dropdown-label">All Projects</span>
<span class="arrow"></span>
</div>
<div class="project-dropdown-menu" id="project-dropdown-menu">
<div class="project-dropdown-actions">
<button id="select-all-projects">All Projects</button>
<button id="clear-all-projects">Clear All</button>
</div>
<div id="project-dropdown-list"></div>
</div>
</div>
<button class="stats-toggle" id="stats-toggle" style="margin-left: auto;">📊 Velocity</button>
<button class="action-btn" id="new-task-btn">+ New Task</button>
</div>
@ -660,7 +783,7 @@
<script>
let projects = [];
let tasks = [];
let selectedProject = null;
let selectedProjects = null; // null = all projects, Set = specific projects
let ws = null;
let draggedTask = null;
let currentView = 'kanban';
@ -775,26 +898,107 @@
}
function renderProjects() {
const filterBar = document.getElementById('filter-bar');
const oldBtns = filterBar.querySelectorAll('.project-btn:not(#all-projects)');
oldBtns.forEach(btn => btn.remove());
const list = document.getElementById('project-dropdown-list');
list.innerHTML = '';
const newTaskBtn = document.getElementById('new-task-btn');
projects.filter(p => tasks.some(t => t.projectName === p.name)).forEach(p => {
const btn = document.createElement('button');
btn.className = 'project-btn';
btn.innerHTML = `<span class="project-dot" style="background:${p.color}"></span>${p.name}`;
btn.onclick = () => {
selectedProject = p.name;
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderTasks();
const activeProjects = projects.filter(p => tasks.some(t => t.projectName === p.name));
activeProjects.forEach(p => {
const item = document.createElement('div');
item.className = 'project-dropdown-item' + (isProjectSelected(p.name) ? ' selected' : '');
item.dataset.project = p.name;
item.innerHTML = `<span class="checkbox"></span><span class="project-dot" style="background:${p.color}"></span>${p.name}`;
item.onclick = (e) => {
e.stopPropagation();
toggleProject(p.name);
};
filterBar.insertBefore(btn, newTaskBtn);
list.appendChild(item);
});
updateDropdownLabel();
document.getElementById('stats').textContent =
`${projects.filter(p => tasks.some(t => t.projectName === p.name)).length} active projects | ${tasks.length} tasks | Updated ${new Date().toLocaleTimeString()}`;
`${activeProjects.length} active projects | ${tasks.length} tasks | Updated ${new Date().toLocaleTimeString()}`;
}
function isProjectSelected(name) {
if (selectedProjects === null) return true;
return selectedProjects.has(name);
}
function toggleProject(name) {
const activeProjects = projects.filter(p => tasks.some(t => t.projectName === p.name));
// If currently "all", switch to all-except-this
if (selectedProjects === null) {
selectedProjects = new Set(activeProjects.map(p => p.name));
selectedProjects.delete(name);
} else if (selectedProjects.has(name)) {
selectedProjects.delete(name);
// If none selected, keep as empty set (shows nothing)
} else {
selectedProjects.add(name);
// If all now selected, switch back to null (all)
if (selectedProjects.size === activeProjects.length) {
selectedProjects = null;
}
}
renderProjects();
renderTasks();
}
function selectAllProjects() {
selectedProjects = null;
renderProjects();
renderTasks();
}
function clearAllProjects() {
selectedProjects = new Set();
renderProjects();
renderTasks();
}
function updateDropdownLabel() {
const label = document.getElementById('dropdown-label');
const dotsContainer = document.getElementById('selected-dots');
dotsContainer.innerHTML = '';
const activeProjects = projects.filter(p => tasks.some(t => t.projectName === p.name));
if (selectedProjects === null || selectedProjects.size === activeProjects.length) {
label.textContent = 'All Projects';
} else if (selectedProjects.size === 0) {
label.textContent = 'No Projects';
} else if (selectedProjects.size === 1) {
const name = [...selectedProjects][0];
const proj = projects.find(p => p.name === name);
if (proj) {
dotsContainer.innerHTML = `<span class="project-dot" style="background:${proj.color}"></span>`;
}
label.textContent = name;
} else {
// Show colored dots for selected projects
[...selectedProjects].forEach(name => {
const proj = projects.find(p => p.name === name);
if (proj) {
const dot = document.createElement('span');
dot.className = 'project-dot';
dot.style.background = proj.color;
dot.style.width = '0.5rem';
dot.style.height = '0.5rem';
dotsContainer.appendChild(dot);
}
});
label.textContent = `${selectedProjects.size} Projects`;
}
// Update checkbox states
document.querySelectorAll('.project-dropdown-item').forEach(item => {
if (isProjectSelected(item.dataset.project)) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
}
async function archiveTask(projectPath, taskId) {
@ -931,7 +1135,7 @@
let counts = { todo: 0, progress: 0, done: 0, wontdo: 0 };
tasks.forEach(task => {
if (selectedProject && task.projectName !== selectedProject) return;
if (selectedProjects !== null && !selectedProjects.has(task.projectName)) return;
if (filter && !task.title.toLowerCase().includes(filter) &&
!task.projectName.toLowerCase().includes(filter)) return;
@ -1199,11 +1403,29 @@
}
document.getElementById('filter').addEventListener('input', renderTasks);
document.getElementById('all-projects').onclick = () => {
selectedProject = null;
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
document.getElementById('all-projects').classList.add('active');
renderTasks();
// Project dropdown toggle
document.getElementById('project-dropdown-trigger').onclick = (e) => {
e.stopPropagation();
document.getElementById('project-dropdown').classList.toggle('open');
};
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
const dd = document.getElementById('project-dropdown');
if (!dd.contains(e.target)) {
dd.classList.remove('open');
}
});
// Prevent dropdown menu clicks from closing
document.getElementById('project-dropdown-menu').onclick = (e) => {
e.stopPropagation();
};
// All / Clear buttons
document.getElementById('select-all-projects').onclick = () => {
selectAllProjects();
};
document.getElementById('clear-all-projects').onclick = () => {
clearAllProjects();
};
// Velocity Statistics Toggle
@ -1244,8 +1466,8 @@
let timesToStart = [];
let weeklyCompletions = [0, 0, 0, 0]; // Last 4 weeks
const filteredTasks = selectedProject
? tasks.filter(t => t.projectName === selectedProject)
const filteredTasks = selectedProjects !== null
? tasks.filter(t => selectedProjects.has(t.projectName))
: tasks;
filteredTasks.forEach(task => {
@ -1370,8 +1592,8 @@
container.innerHTML = '';
// Filter tasks - show ALL tasks, not just those with dependencies
let filteredTasks = selectedProject
? tasks.filter(t => t.projectName === selectedProject)
let filteredTasks = selectedProjects !== null
? tasks.filter(t => selectedProjects.has(t.projectName))
: tasks;
if (!showCompleted) {