Compare commits

..

5 Commits

Author SHA1 Message Date
serversdown
5da6e075b4 feat: add task blocker dependency graph + actionable now view
- New task_blockers join table (many-to-many, cross-project)
- Cycle detection prevents circular dependencies
- GET /api/tasks/{id}/blockers and /blocking endpoints
- POST/DELETE /api/tasks/{id}/blockers/{blocker_id}
- GET /api/actionable — all non-done tasks with no incomplete blockers
- BlockerPanel.jsx — search & manage blockers per task (via task menu)
- ActionableView.jsx — "what can I do right now?" dashboard grouped by project
- "Now" button in nav header routes to actionable view
- migrate_add_blockers.py migration script for existing databases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:08:55 -04:00
serversdwn
c6ed57342c Merge remote branch and resolve conflicts with BIT rename
Kept remote's pydantic-settings, env_file, SearchBar, and new components.
Applied BIT/Break It Down naming throughout conflicted files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 22:56:08 +00:00
serversdwn
5d5cec048f chore: reband cleanup, changed names from tesseract 2026-02-17 22:50:57 +00:00
01e594b941 Merge pull request 'Main branch Resync on w/ gitea. v0.1.6' (#1) from claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5 into main
Reviewed-on: http://10.0.0.2:9010/serversdown/break-it-down/pulls/1
2026-01-04 04:30:52 -05:00
serversdwn
3fc90063b4 rebranded to BIT 2026-01-04 09:24:06 +00:00
22 changed files with 2186 additions and 1198 deletions

160
ARCHIVE_FEATURE_README.md Normal file
View File

@@ -0,0 +1,160 @@
# Project Archive Feature
## Overview
The Break It Down (BIT) application now supports archiving projects! This helps you organize your workspace by hiding completed or inactive projects from the main view while keeping them accessible.
## Features Added
### 1. **Archive Status**
- Projects can be marked as archived or active
- Archived projects are kept in the database but hidden from the default view
- All tasks and data are preserved when archiving
### 2. **Tab Navigation**
The main Projects page now has three tabs:
- **Active** (default) - Shows only non-archived projects
- **Archived** - Shows only archived projects
- **All** - Shows all projects regardless of archive status
### 3. **Quick Actions**
Each project card now has two action buttons:
- **Archive/Unarchive** (📦/↩️) - Toggle archive status
- **Delete** (🗑️) - Permanently delete the project
### 4. **Visual Indicators**
- Archived projects appear with reduced opacity and gray border
- "(archived)" label appears next to archived project names
- Context-aware empty state messages
## Database Changes
A new column has been added to the `projects` table:
- `is_archived` (BOOLEAN) - Defaults to `False` for new projects
## Installation & Migration
### For New Installations
No action needed! The database will be created with the correct schema automatically.
### For Existing Databases
If you have an existing Break It Down database, run the migration script:
```bash
cd backend
python3 migrate_add_is_archived.py
```
This will add the `is_archived` column to your existing projects table and set all existing projects to `is_archived=False`.
### Restart the Backend
After migration, restart the backend server to load the updated models:
```bash
# Stop the current backend process
pkill -f "uvicorn.*backend.main:app"
# Start the backend again
cd /path/to/break-it-down
uvicorn backend.main:app --host 0.0.0.0 --port 8001
```
Or if using Docker:
```bash
docker-compose restart backend
```
## API Changes
### Updated Endpoint: GET /api/projects
New optional query parameter:
- `archived` (boolean, optional) - Filter projects by archive status
- `archived=false` - Returns only active projects
- `archived=true` - Returns only archived projects
- No parameter - Returns all projects
Examples:
```bash
# Get active projects
GET /api/projects?archived=false
# Get archived projects
GET /api/projects?archived=true
# Get all projects
GET /api/projects
```
### Updated Endpoint: PUT /api/projects/{id}
New field in request body:
```json
{
"name": "Project Name", // optional
"description": "Description", // optional
"statuses": [...], // optional
"is_archived": true // optional - new!
}
```
### New API Helper Functions
The frontend API client now includes:
- `archiveProject(id)` - Archive a project
- `unarchiveProject(id)` - Unarchive a project
## File Changes Summary
### Backend
- `backend/app/models.py` - Added `is_archived` Boolean column to Project model
- `backend/app/schemas.py` - Updated ProjectUpdate and Project schemas
- `backend/app/main.py` - Added `archived` query parameter to list_projects endpoint
- `backend/app/crud.py` - Updated get_projects to support archive filtering
- `backend/migrate_add_is_archived.py` - Migration script (new file)
### Frontend
- `frontend/src/pages/ProjectList.jsx` - Added tabs, archive buttons, and filtering logic
- `frontend/src/utils/api.js` - Added archive/unarchive functions and updated getProjects
## Usage Examples
### Archive a Project
1. Go to the Projects page
2. Click the archive icon (📦) on any project card
3. The project disappears from the Active view
4. Switch to the "Archived" tab to see it
### Unarchive a Project
1. Switch to the "Archived" tab
2. Click the unarchive icon (↩️) on the project card
3. The project returns to the Active view
### View All Projects
Click the "All" tab to see both active and archived projects together.
## Status vs Archive
**Important distinction:**
- **Task Status** (backlog, in_progress, on_hold, done) - Applied to individual tasks within a project
- **Project Archive** - Applied to entire projects to organize your workspace
The existing task status "on_hold" is still useful for pausing work on specific tasks, while archiving is for entire projects you want to hide from your main view.
## Backward Compatibility
All changes are backward compatible:
- Existing projects will default to `is_archived=false` (active)
- Old API calls without the `archived` parameter still work (returns all projects)
- The frontend gracefully handles projects with or without the archive field
## Future Enhancements
Potential additions:
- Archive date tracking
- Bulk archive operations
- Archive projects from the ProjectSettings modal
- Auto-archive projects after X days of inactivity
- Project status badges (active, archived, on-hold, completed)

View File

@@ -1,11 +1,11 @@
# Changelog
All notable changes to TESSERACT will be documented in this file.
All notable changes to Break It Down (BIT) will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.6] - 2025-01-25
## [0.1.6] - 2025-11-25
### Added
- **Dynamic Status Management System**
@@ -69,7 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Helper functions for status formatting and color coding
- Prop drilling of projectStatuses through component hierarchy
## [0.1.5] - 2025-01-XX
## [0.1.5] - 2025-11-22
### Added
- **Nested Kanban View** - Major feature implementation
@@ -238,9 +238,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Project Information
**TESSERACT** - Task Decomposition Engine
**Break It Down (BIT)** - Task Decomposition Engine
A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
**Repository**: https://github.com/serversdwn/tesseract
**Repository**: https://github.com/serversdwn/break-it-down
**License**: MIT
**Author**: serversdwn

View File

@@ -1,4 +1,4 @@
# TESSERACT
# Break It Down - BIT
**Task Decomposition Engine** - A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
@@ -7,7 +7,7 @@
## Overview
TESSERACT is designed for complex project management where tasks naturally decompose into hierarchical structures. Whether you're breaking down software projects, research tasks, or multi-phase initiatives, TESSERACT helps you visualize, track, and manage work at any level of granularity.
Break It Down is designed for complex project management where tasks naturally decompose into hierarchical structures. Whether you're breaking down software projects, research tasks, or multi-phase initiatives, BIT helps you visualize, track, and manage work at any level of granularity.
### Key Features
@@ -33,7 +33,6 @@ TESSERACT is designed for complex project management where tasks naturally decom
- **LLM Integration**: Import JSON task trees generated by AI assistants
- **Real-Time Search**: Find tasks across projects with filtering
- **Self-Hosted**: Full data ownership and privacy
- **Dark Cyberpunk UI**: Orange-accented dark theme optimized for focus
## Tech Stack
@@ -55,8 +54,8 @@ TESSERACT is designed for complex project management where tasks naturally decom
1. **Clone the repository**
```bash
git clone https://github.com/serversdwn/tesseract.git
cd tesseract
git clone https://github.com/serversdwn/break-it-down.git
cd break-it-down
```
2. **Start the application**
@@ -166,7 +165,7 @@ The Kanban board displays tasks in a nested hierarchy while maintaining status-b
### Understanding Time Estimates
TESSERACT uses **leaf-based time calculation** for accurate project planning:
Break It Down uses **leaf-based time calculation** for accurate project planning:
- **Leaf Tasks** (no subtasks): Display shows their own time estimate
- **Parent Tasks** (have subtasks): Display shows sum of ALL descendant leaf tasks
@@ -202,7 +201,7 @@ Remaining work: 3h 30m (not 10h!)
### JSON Import
TESSERACT can import task hierarchies from JSON files, making it perfect for LLM-generated project breakdowns.
Break It Down can import task hierarchies from JSON files, making it perfect for LLM-generated project breakdowns.
1. Click "Import JSON" on a project
2. Upload a file matching this structure:
@@ -307,7 +306,7 @@ tasks
### Project Structure
```
tesseract/
break-it-down/
├─ backend/
│ ├─ app/
│ │ ├─ main.py # FastAPI application
@@ -370,10 +369,10 @@ Frontend will be available at `http://localhost:5173` (Vite default)
**Backup:**
```bash
# Docker
docker cp tesseract-backend:/app/tesseract.db ./backup.db
docker cp bit-backend:/app/bit.db ./backup.db
# Local
cp backend/tesseract.db ./backup.db
cp backend/bit.db ./backup.db
```
**Schema Changes:**
@@ -456,8 +455,8 @@ MIT License - see LICENSE file for details
## Support
- **Issues**: https://github.com/serversdwn/tesseract/issues
- **Discussions**: https://github.com/serversdwn/tesseract/discussions
- **Issues**: https://github.com/serversdwn/break-it-down/issues
- **Discussions**: https://github.com/serversdwn/break-it-down/discussions
## Acknowledgments
@@ -467,4 +466,4 @@ MIT License - see LICENSE file for details
---
**TESSERACT** - Decompose complexity, achieve clarity.
**Break It Down (BIT)** - Decompose complexity, achieve clarity.

View File

@@ -1,8 +1,8 @@
# Database Configuration
DATABASE_URL=sqlite:///./tesseract.db
DATABASE_URL=sqlite:///./bit.db
# API Configuration
API_TITLE=Tesseract - Nested Todo Tree API
API_TITLE=Break It Down (BIT) - Nested Todo Tree API
API_DESCRIPTION=API for managing deeply nested todo trees
API_VERSION=1.0.0

View File

@@ -21,8 +21,14 @@ def get_project(db: Session, project_id: int) -> Optional[models.Project]:
return db.query(models.Project).filter(models.Project.id == project_id).first()
def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]:
return db.query(models.Project).offset(skip).limit(limit).all()
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
query = db.query(models.Project)
# Filter by archive status if specified
if archived is not None:
query = query.filter(models.Project.is_archived == archived)
return query.offset(skip).limit(limit).all()
def update_project(
@@ -180,3 +186,98 @@ def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[model
models.Task.project_id == project_id,
models.Task.status == status
).all()
# ========== BLOCKER CRUD ==========
def _has_cycle(db: Session, start_id: int, target_id: int) -> bool:
"""BFS from start_id following its blockers. Returns True if target_id is reachable,
which would mean adding target_id as a blocker of start_id creates a cycle."""
visited = set()
queue = [start_id]
while queue:
current = queue.pop(0)
if current == target_id:
return True
if current in visited:
continue
visited.add(current)
task = db.query(models.Task).filter(models.Task.id == current).first()
if task:
for b in task.blockers:
if b.id not in visited:
queue.append(b.id)
return False
def add_blocker(db: Session, task_id: int, blocker_id: int) -> models.Task:
"""Add blocker_id as a prerequisite of task_id.
Raises ValueError on self-reference or cycle."""
if task_id == blocker_id:
raise ValueError("A task cannot block itself")
task = get_task(db, task_id)
blocker = get_task(db, blocker_id)
if not task:
raise ValueError("Task not found")
if not blocker:
raise ValueError("Blocker task not found")
# Already linked — idempotent
if any(b.id == blocker_id for b in task.blockers):
return task
# Cycle detection: would blocker_id eventually depend on task_id?
if _has_cycle(db, blocker_id, task_id):
raise ValueError("Adding this blocker would create a circular dependency")
task.blockers.append(blocker)
db.commit()
db.refresh(task)
return task
def remove_blocker(db: Session, task_id: int, blocker_id: int) -> bool:
"""Remove blocker_id as a prerequisite of task_id."""
task = get_task(db, task_id)
blocker = get_task(db, blocker_id)
if not task or not blocker:
return False
if not any(b.id == blocker_id for b in task.blockers):
return False
task.blockers.remove(blocker)
db.commit()
return True
def get_task_with_blockers(db: Session, task_id: int) -> Optional[models.Task]:
"""Get a task including its blockers and blocking lists."""
return db.query(models.Task).filter(models.Task.id == task_id).first()
def get_actionable_tasks(db: Session) -> List[dict]:
"""Return all non-done tasks that have no incomplete blockers, with project name."""
tasks = db.query(models.Task).filter(
models.Task.status != "done"
).all()
result = []
for task in tasks:
incomplete_blockers = [b for b in task.blockers if b.status != "done"]
if not incomplete_blockers:
result.append({
"id": task.id,
"title": task.title,
"project_id": task.project_id,
"project_name": task.project.name,
"status": task.status,
"estimated_minutes": task.estimated_minutes,
"tags": task.tags,
"flag_color": task.flag_color,
})
return result

View File

@@ -30,9 +30,14 @@ app.add_middleware(
# ========== PROJECT ENDPOINTS ==========
@app.get("/api/projects", response_model=List[schemas.Project])
def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
"""List all projects"""
return crud.get_projects(db, skip=skip, limit=limit)
def list_projects(
skip: int = 0,
limit: int = 100,
archived: Optional[bool] = None,
db: Session = Depends(get_db)
):
"""List all projects with optional archive filter"""
return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
@@ -154,6 +159,50 @@ def delete_task(task_id: int, db: Session = Depends(get_db)):
return None
# ========== BLOCKER ENDPOINTS ==========
@app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo])
def get_task_blockers(task_id: int, db: Session = Depends(get_db)):
"""Get all tasks that are blocking a given task."""
task = crud.get_task_with_blockers(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task.blockers
@app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo])
def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)):
"""Get all tasks that this task is currently blocking."""
task = crud.get_task_with_blockers(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task.blocking
@app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201)
def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
"""Add blocker_id as a prerequisite of task_id."""
try:
crud.add_blocker(db, task_id, blocker_id)
return {"status": "ok"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204)
def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
"""Remove blocker_id as a prerequisite of task_id."""
if not crud.remove_blocker(db, task_id, blocker_id):
raise HTTPException(status_code=404, detail="Blocker relationship not found")
return None
@app.get("/api/actionable", response_model=List[schemas.ActionableTask])
def get_actionable_tasks(db: Session = Depends(get_db)):
"""Get all non-done tasks with no incomplete blockers, across all projects."""
return crud.get_actionable_tasks(db)
# ========== SEARCH ENDPOINT ==========
@app.get("/api/search", response_model=List[schemas.Task])
@@ -314,6 +363,6 @@ def root():
"""API health check"""
return {
"status": "online",
"message": "Tesseract API - Nested Todo Tree Manager",
"message": "Break It Down (BIT) API",
"docs": "/docs"
}

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table
from sqlalchemy.orm import relationship
from datetime import datetime
from .database import Base
@@ -8,6 +8,15 @@ from .database import Base
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
# Association table for task blocker relationships (many-to-many)
task_blockers = Table(
"task_blockers",
Base.metadata,
Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
)
class Project(Base):
__tablename__ = "projects"
@@ -15,6 +24,7 @@ class Project(Base):
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -39,3 +49,13 @@ class Task(Base):
project = relationship("Project", back_populates="tasks")
parent = relationship("Task", remote_side=[id], backref="subtasks")
# blockers: tasks that must be done before this task can start
# blocking: tasks that this task is holding up
blockers = relationship(
"Task",
secondary=task_blockers,
primaryjoin=lambda: Task.id == task_blockers.c.task_id,
secondaryjoin=lambda: Task.id == task_blockers.c.blocked_by_id,
backref="blocking",
)

View File

@@ -46,6 +46,37 @@ class TaskWithSubtasks(Task):
model_config = ConfigDict(from_attributes=True)
class BlockerInfo(BaseModel):
"""Lightweight task info used when listing blockers/blocking relationships."""
id: int
title: str
project_id: int
status: str
model_config = ConfigDict(from_attributes=True)
class TaskWithBlockers(Task):
blockers: List[BlockerInfo] = []
blocking: List[BlockerInfo] = []
model_config = ConfigDict(from_attributes=True)
class ActionableTask(BaseModel):
"""A task that is ready to work on — not done, and all blockers are resolved."""
id: int
title: str
project_id: int
project_name: str
status: str
estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# Project Schemas
class ProjectBase(BaseModel):
name: str
@@ -60,11 +91,13 @@ class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
statuses: Optional[List[str]] = None
is_archived: Optional[bool] = None
class Project(ProjectBase):
id: int
statuses: List[str]
is_archived: bool
created_at: datetime
updated_at: datetime

View File

@@ -6,10 +6,10 @@ class Settings(BaseSettings):
"""Application settings loaded from environment variables"""
# Database Configuration
database_url: str = "sqlite:///./tesseract.db"
database_url: str = "sqlite:///./bit.db"
# API Configuration
api_title: str = "Tesseract - Nested Todo Tree API"
api_title: str = "Break It Down (BIT) - Nested Todo Tree API"
api_description: str = "API for managing deeply nested todo trees"
api_version: str = "1.0.0"

View File

@@ -0,0 +1,40 @@
"""
Migration script to add the task_blockers association table.
Run this once if you have an existing database.
Usage (from inside the backend container or with the venv active):
python migrate_add_blockers.py
"""
import sqlite3
import os
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
if not os.path.exists(db_path):
print(f"Database not found at {db_path}")
print("No migration needed — new database will be created with the correct schema.")
exit(0)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if the table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_blockers'")
if cursor.fetchone():
print("Table 'task_blockers' already exists. Migration not needed.")
else:
cursor.execute("""
CREATE TABLE task_blockers (
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
blocked_by_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, blocked_by_id)
)
""")
conn.commit()
print("Successfully created 'task_blockers' table.")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
finally:
conn.close()

View File

@@ -0,0 +1,37 @@
"""
Migration script to add is_archived column to existing projects.
Run this once if you have an existing database.
"""
import sqlite3
import os
# Get the database path
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
if not os.path.exists(db_path):
print(f"Database not found at {db_path}")
print("No migration needed - new database will be created with the correct schema.")
exit(0)
# Connect to the database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if the column already exists
cursor.execute("PRAGMA table_info(projects)")
columns = [column[1] for column in cursor.fetchall()]
if 'is_archived' in columns:
print("Column 'is_archived' already exists. Migration not needed.")
else:
# Add the is_archived column
cursor.execute("ALTER TABLE projects ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0")
conn.commit()
print("Successfully added 'is_archived' column to projects table.")
print("All existing projects have been set to is_archived=False (0).")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
finally:
conn.close()

View File

@@ -10,7 +10,7 @@ DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
def migrate():
# Connect to the database
conn = sqlite3.connect('tesseract.db')
conn = sqlite3.connect('bit.db')
cursor = conn.cursor()
try:

View File

@@ -3,12 +3,12 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: tesseract-backend
container_name: bit-backend
ports:
- "8002:8002"
volumes:
- ./backend/app:/app/app
- tesseract-db:/app
- bit-db:/app
env_file:
- ./backend/.env
environment:
@@ -19,7 +19,7 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: tesseract-frontend
container_name: bit-frontend
ports:
- "3002:80"
env_file:
@@ -29,4 +29,4 @@ services:
restart: unless-stopped
volumes:
tesseract-db:
bit-db:

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tesseract - Task Decomposition Engine</title>
<title>BIT - Break It Down</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,11 +1,11 @@
{
"name": "tesseract-frontend",
"name": "bit-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tesseract-frontend",
"name": "bit-frontend",
"version": "1.0.0",
"dependencies": {
"lucide-react": "^0.303.0",

View File

@@ -1,5 +1,5 @@
{
"name": "tesseract-frontend",
"name": "bit-frontend",
"private": true,
"version": "1.0.0",
"type": "module",

View File

@@ -1,20 +1,41 @@
import { Routes, Route } from 'react-router-dom'
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import { Zap } from 'lucide-react'
import ProjectList from './pages/ProjectList'
import ProjectView from './pages/ProjectView'
import ActionableView from './pages/ActionableView'
import SearchBar from './components/SearchBar'
function App() {
const navigate = useNavigate()
const location = useLocation()
const isActionable = location.pathname === '/actionable'
return (
<div className="min-h-screen bg-cyber-dark">
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="container mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-cyber-orange">
TESSERACT
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
<div className="flex items-center gap-4">
<h1
className="text-2xl font-bold text-cyber-orange cursor-pointer"
onClick={() => navigate('/')}
>
BIT
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
<button
onClick={() => navigate(isActionable ? '/' : '/actionable')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-all ${
isActionable
? 'bg-cyber-orange text-cyber-darkest'
: 'border border-cyber-orange/40 text-cyber-orange hover:bg-cyber-orange/10'
}`}
title="What can I do right now?"
>
<Zap size={14} />
Now
</button>
</div>
<SearchBar />
</div>
@@ -25,6 +46,7 @@ function App() {
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/project/:projectId" element={<ProjectView />} />
<Route path="/actionable" element={<ActionableView />} />
</Routes>
</main>
</div>

View File

@@ -0,0 +1,201 @@
import { useState, useEffect, useRef } from 'react'
import { X, Search, Lock, Unlock, AlertTriangle } from 'lucide-react'
import { getTaskBlockers, addBlocker, removeBlocker, searchTasks } from '../utils/api'
function BlockerPanel({ task, onClose, onUpdate }) {
const [blockers, setBlockers] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const searchTimeout = useRef(null)
useEffect(() => {
loadBlockers()
}, [task.id])
const loadBlockers = async () => {
try {
setLoading(true)
const data = await getTaskBlockers(task.id)
setBlockers(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleSearch = (query) => {
setSearchQuery(query)
setError('')
if (searchTimeout.current) clearTimeout(searchTimeout.current)
if (!query.trim()) {
setSearchResults([])
return
}
searchTimeout.current = setTimeout(async () => {
try {
setSearching(true)
const results = await searchTasks(query)
// Filter out the current task and tasks already blocking this one
const blockerIds = new Set(blockers.map(b => b.id))
const filtered = results.filter(t => t.id !== task.id && !blockerIds.has(t.id))
setSearchResults(filtered.slice(0, 8))
} catch (err) {
setError(err.message)
} finally {
setSearching(false)
}
}, 300)
}
const handleAddBlocker = async (blocker) => {
try {
setError('')
await addBlocker(task.id, blocker.id)
setBlockers(prev => [...prev, blocker])
setSearchResults(prev => prev.filter(t => t.id !== blocker.id))
setSearchQuery('')
setSearchResults([])
onUpdate()
} catch (err) {
setError(err.message)
}
}
const handleRemoveBlocker = async (blockerId) => {
try {
setError('')
await removeBlocker(task.id, blockerId)
setBlockers(prev => prev.filter(b => b.id !== blockerId))
onUpdate()
} catch (err) {
setError(err.message)
}
}
const incompleteBlockers = blockers.filter(b => b.status !== 'done')
const isBlocked = incompleteBlockers.length > 0
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-cyber-darkest border border-cyber-orange/50 rounded-lg w-full max-w-lg shadow-xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-cyber-orange/20">
<div className="flex items-center gap-2">
{isBlocked
? <Lock size={16} className="text-red-400" />
: <Unlock size={16} className="text-green-400" />
}
<span className="font-semibold text-gray-100 text-sm">Blockers</span>
<span className="text-xs text-gray-500 ml-1 truncate max-w-[200px]" title={task.title}>
{task.title}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200 transition-colors">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-4">
{/* Status banner */}
{isBlocked ? (
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/20 border border-red-500/30 rounded text-red-300 text-sm">
<AlertTriangle size={14} />
<span>{incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} this task is locked</span>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/20 border border-green-500/30 rounded text-green-300 text-sm">
<Unlock size={14} />
<span>No active blockers this task is ready to work on</span>
</div>
)}
{/* Current blockers list */}
{loading ? (
<div className="text-gray-500 text-sm text-center py-2">Loading...</div>
) : blockers.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Blocked by</p>
{blockers.map(b => (
<div
key={b.id}
className="flex items-center justify-between px-3 py-2 bg-cyber-darker rounded border border-cyber-orange/10"
>
<div className="flex items-center gap-2 min-w-0">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${b.status === 'done' ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-sm text-gray-200 truncate">{b.title}</span>
<span className="text-xs text-gray-500 flex-shrink-0">#{b.id}</span>
</div>
<button
onClick={() => handleRemoveBlocker(b.id)}
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0 ml-2"
title="Remove blocker"
>
<X size={14} />
</button>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-600 text-center py-1">No blockers added yet</p>
)}
{/* Search to add blocker */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Add a blocker</p>
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
placeholder="Search tasks across all projects..."
className="w-full pl-9 pr-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/>
</div>
{/* Search results */}
{(searchResults.length > 0 || searching) && (
<div className="mt-1 border border-cyber-orange/20 rounded overflow-hidden">
{searching && (
<div className="px-3 py-2 text-xs text-gray-500">Searching...</div>
)}
{searchResults.map(result => (
<button
key={result.id}
onClick={() => handleAddBlocker(result)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-left transition-colors border-b border-cyber-orange/10 last:border-0"
>
<span className="text-sm text-gray-200 truncate flex-1">{result.title}</span>
<span className="text-xs text-gray-600 flex-shrink-0">project #{result.project_id}</span>
</button>
))}
{!searching && searchResults.length === 0 && searchQuery && (
<div className="px-3 py-2 text-xs text-gray-500">No matching tasks found</div>
)}
</div>
)}
</div>
{error && (
<div className="px-3 py-2 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
{error}
</div>
)}
</div>
</div>
</div>
)
}
export default BlockerPanel

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText, Lock } from 'lucide-react'
import { updateTask } from '../utils/api'
import BlockerPanel from './BlockerPanel'
const FLAG_COLORS = [
{ name: 'red', color: 'bg-red-500' },
@@ -35,6 +36,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
const [showTagsEdit, setShowTagsEdit] = useState(false)
const [showFlagEdit, setShowFlagEdit] = useState(false)
const [showStatusEdit, setShowStatusEdit] = useState(false)
const [showBlockerPanel, setShowBlockerPanel] = useState(false)
// Calculate hours and minutes from task.estimated_minutes
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
@@ -371,6 +373,19 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
</button>
)}
{/* Manage Blockers */}
<button
onClick={(e) => {
e.stopPropagation()
setShowBlockerPanel(true)
setIsOpen(false)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Lock size={14} />
<span>Manage Blockers</span>
</button>
{/* Edit Title */}
<button
onClick={(e) => {
@@ -398,6 +413,15 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
</button>
</div>
)}
{/* Blocker panel modal */}
{showBlockerPanel && (
<BlockerPanel
task={task}
onClose={() => setShowBlockerPanel(false)}
onUpdate={onUpdate}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Zap, Clock, RefreshCw, ChevronRight, Check } from 'lucide-react'
import { getActionableTasks, updateTask } from '../utils/api'
const FLAG_DOT = {
red: 'bg-red-500',
orange: 'bg-orange-500',
yellow: 'bg-yellow-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500',
}
const formatTime = (minutes) => {
if (!minutes) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h && m) return `${h}h ${m}m`
if (h) return `${h}h`
return `${m}m`
}
const formatStatusLabel = (status) =>
status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
const getStatusColor = (status) => {
const s = status.toLowerCase()
if (s === 'in_progress' || s.includes('progress')) return 'text-blue-400'
if (s === 'on_hold' || s.includes('hold')) return 'text-yellow-400'
return 'text-gray-500'
}
function ActionableView() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [completingId, setCompletingId] = useState(null)
const navigate = useNavigate()
useEffect(() => {
loadTasks()
}, [])
const loadTasks = async () => {
try {
setLoading(true)
setError('')
const data = await getActionableTasks()
setTasks(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleMarkDone = async (task) => {
try {
setCompletingId(task.id)
await updateTask(task.id, { status: 'done' })
// Remove from list and reload to surface newly unblocked tasks
setTasks(prev => prev.filter(t => t.id !== task.id))
// Reload after a short beat so the user sees the removal first
setTimeout(() => loadTasks(), 600)
} catch (err) {
setError(err.message)
} finally {
setCompletingId(null)
}
}
// Group by project
const byProject = tasks.reduce((acc, task) => {
const key = task.project_id
if (!acc[key]) acc[key] = { name: task.project_name, tasks: [] }
acc[key].tasks.push(task)
return acc
}, {})
const projectGroups = Object.entries(byProject)
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<Zap size={24} className="text-cyber-orange" />
<div>
<h2 className="text-2xl font-bold text-gray-100">What can I do right now?</h2>
<p className="text-sm text-gray-500 mt-0.5">
{loading ? '...' : `${tasks.length} task${tasks.length !== 1 ? 's' : ''} ready across all projects`}
</p>
</div>
</div>
<button
onClick={loadTasks}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-gray-200 border border-cyber-orange/20 hover:border-cyber-orange/40 rounded transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{error && (
<div className="mb-6 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
{error}
</div>
)}
{!loading && tasks.length === 0 && (
<div className="text-center py-20 text-gray-600">
<Zap size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-lg">Nothing actionable right now.</p>
<p className="text-sm mt-1">All tasks are either done or blocked.</p>
</div>
)}
{/* Project groups */}
<div className="space-y-8">
{projectGroups.map(([projectId, group]) => (
<div key={projectId}>
{/* Project header */}
<button
onClick={() => navigate(`/project/${projectId}`)}
className="flex items-center gap-2 mb-3 text-cyber-orange hover:text-cyber-orange-bright transition-colors group"
>
<span className="text-sm font-semibold uppercase tracking-wider">{group.name}</span>
<ChevronRight size={14} className="opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
{/* Task cards */}
<div className="space-y-2">
{group.tasks.map(task => (
<div
key={task.id}
className={`flex items-center gap-3 px-4 py-3 bg-cyber-darkest border border-cyber-orange/20 rounded-lg hover:border-cyber-orange/40 transition-all ${
completingId === task.id ? 'opacity-50' : ''
}`}
>
{/* Done button */}
<button
onClick={() => handleMarkDone(task)}
disabled={completingId === task.id}
className="flex-shrink-0 w-6 h-6 rounded-full border-2 border-cyber-orange/40 hover:border-green-400 hover:bg-green-400/10 flex items-center justify-center transition-all group"
title="Mark as done"
>
<Check size={12} className="text-transparent group-hover:text-green-400 transition-colors" />
</button>
{/* Flag dot */}
{task.flag_color && (
<span className={`flex-shrink-0 w-2 h-2 rounded-full ${FLAG_DOT[task.flag_color] || 'bg-gray-500'}`} />
)}
{/* Title + meta */}
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-100">{task.title}</span>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{task.status !== 'backlog' && (
<span className={`text-xs ${getStatusColor(task.status)}`}>
{formatStatusLabel(task.status)}
</span>
)}
{task.estimated_minutes && (
<span className="flex items-center gap-1 text-xs text-gray-600">
<Clock size={10} />
{formatTime(task.estimated_minutes)}
</span>
)}
{task.tags && task.tags.map(tag => (
<span key={tag} className="text-xs text-cyber-orange/60 bg-cyber-orange/5 px-1.5 py-0.5 rounded">
{tag}
</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
export default ActionableView

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Upload, Trash2 } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON } from '../utils/api'
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
function ProjectList() {
const [projects, setProjects] = useState([])
@@ -12,15 +12,22 @@ function ProjectList() {
const [newProjectDesc, setNewProjectDesc] = useState('')
const [importJSON_Text, setImportJSONText] = useState('')
const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
const navigate = useNavigate()
useEffect(() => {
loadProjects()
}, [])
}, [activeTab])
const loadProjects = async () => {
try {
const data = await getProjects()
setLoading(true)
let archivedFilter = null
if (activeTab === 'active') archivedFilter = false
if (activeTab === 'archived') archivedFilter = true
// 'all' tab uses null to get all projects
const data = await getProjects(archivedFilter)
setProjects(data)
} catch (err) {
setError(err.message)
@@ -72,13 +79,33 @@ function ProjectList() {
}
}
const handleArchiveProject = async (projectId, e) => {
e.stopPropagation()
try {
await archiveProject(projectId)
setProjects(projects.filter(p => p.id !== projectId))
} catch (err) {
setError(err.message)
}
}
const handleUnarchiveProject = async (projectId, e) => {
e.stopPropagation()
try {
await unarchiveProject(projectId)
setProjects(projects.filter(p => p.id !== projectId))
} catch (err) {
setError(err.message)
}
}
if (loading) {
return <div className="text-center text-gray-400 py-12">Loading...</div>
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-bold text-gray-100">Projects</h2>
<div className="flex gap-3">
<button
@@ -98,6 +125,40 @@ function ProjectList() {
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
<button
onClick={() => setActiveTab('active')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'active'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Active
</button>
<button
onClick={() => setActiveTab('archived')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'archived'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Archived
</button>
<button
onClick={() => setActiveTab('all')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'all'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
All
</button>
</div>
{error && (
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
{error}
@@ -106,8 +167,14 @@ function ProjectList() {
{projects.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-xl mb-2">No projects yet</p>
<p className="text-sm">Create a new project or import from JSON</p>
<p className="text-xl mb-2">
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
</p>
<p className="text-sm">
{activeTab === 'archived'
? 'Archive projects to keep them out of your active workspace'
: 'Create a new project or import from JSON'}
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -115,19 +182,46 @@ function ProjectList() {
<div
key={project.id}
onClick={() => navigate(`/project/${project.id}`)}
className="p-6 bg-cyber-darkest border border-cyber-orange/30 rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group"
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
project.is_archived
? 'border-gray-700 opacity-75'
: 'border-cyber-orange/30'
}`}
>
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
{project.name}
{project.is_archived && (
<span className="ml-2 text-xs text-gray-500">(archived)</span>
)}
</h3>
<div className="flex gap-2">
{project.is_archived ? (
<button
onClick={(e) => handleUnarchiveProject(project.id, e)}
className="text-gray-600 hover:text-cyber-orange transition-colors"
title="Unarchive project"
>
<ArchiveRestore size={18} />
</button>
) : (
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
>
<Trash2 size={18} />
</button>
</div>
</div>
{project.description && (
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
)}

View File

@@ -22,7 +22,14 @@ async function fetchAPI(endpoint, options = {}) {
}
// Projects
export const getProjects = () => fetchAPI('/projects');
export const getProjects = (archived = null) => {
const params = new URLSearchParams();
if (archived !== null) {
params.append('archived', archived);
}
const queryString = params.toString();
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
};
export const getProject = (id) => fetchAPI(`/projects/${id}`);
export const createProject = (data) => fetchAPI('/projects', {
method: 'POST',
@@ -33,6 +40,8 @@ export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
body: JSON.stringify(data),
});
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
export const archiveProject = (id) => updateProject(id, { is_archived: true });
export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
// Tasks
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
@@ -57,6 +66,15 @@ export const importJSON = (data) => fetchAPI('/import-json', {
body: JSON.stringify(data),
});
// Blockers
export const getTaskBlockers = (taskId) => fetchAPI(`/tasks/${taskId}/blockers`);
export const getTaskBlocking = (taskId) => fetchAPI(`/tasks/${taskId}/blocking`);
export const addBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'POST' });
export const removeBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'DELETE' });
// Actionable tasks (no incomplete blockers, not done)
export const getActionableTasks = () => fetchAPI('/actionable');
// Search
export const searchTasks = (query, projectIds = null) => {
const params = new URLSearchParams({ query });