diff --git a/PROGRESS.md b/PROGRESS.md index 66539f1..5969834 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -63,6 +63,9 @@ 19. **Dynamic UI Integration:** Completely refactored the navigation and boards to build columns and tabs dynamically from the live database user list. 20. **Functional Search:** Implemented a real-time task search feature. Clicking the search icon now reveals an inline search bar that filters tasks by title, description, or tags across all views. 21. **Advanced Audit Filtering:** Upgraded the Audit Log for Admins with a search bar and a granular event type filter. Admins can now filter the history by specific actions (e.g., "deleted tasks", "moved tasks", "user management") and search through summaries in real-time. +22. **Persistent Task Reordering:** Implemented drag-and-drop reordering within and between columns. + - **Backend:** Added a `position` (Float) column to the `tasks` table and updated API endpoints to support position updates and sorted fetching. + - **UI:** Enhanced the Kanban board with a "drop-between-cards" detection logic and a visual blue drop indicator. Positions are persisted to the database instantly on drop. ### Phase 3: Advanced Features - **Real-time Notifications:** Explore WebSockets for task assignments. diff --git a/app.jsx b/app.jsx index d6799f0..f9b2292 100644 --- a/app.jsx +++ b/app.jsx @@ -169,9 +169,11 @@ function App() { } }; - const moveTask = async (taskId, toUserId) => { + const moveTask = async (taskId, toUserId, position = null) => { try { - await api.updateTask(taskId, { assignee_id: toUserId }); + const updates = { assignee_id: toUserId }; + if (position !== null) updates.position = position; + await api.updateTask(taskId, updates); await api.addAudit({ actor: meId, action: 'task_moved', summary: 'Moved task to ' + (merge(toUserId)||{}).name, target: taskId }); } catch(e) {} }; diff --git a/backend/main.py b/backend/main.py index d211daa..8f2cfcb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -99,17 +99,21 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user: model @app.get("/tasks", response_model=List[schemas.Task]) def read_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): - return db.query(models.Task).filter(models.Task.deleted_at == None).all() + return db.query(models.Task).filter(models.Task.deleted_at == None).order_by(models.Task.position.asc()).all() @app.get("/tasks/deleted", response_model=List[schemas.Task]) def read_deleted_tasks(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.account_type != "admin": raise HTTPException(status_code=403, detail="Not enough permissions") - return db.query(models.Task).filter(models.Task.deleted_at != None).all() + return db.query(models.Task).filter(models.Task.deleted_at != None).order_by(models.Task.position.asc()).all() @app.post("/tasks", response_model=schemas.Task) def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): task_id = task.id or f"t_{uuid.uuid4().hex[:8]}" + + # Calculate position (max in column + 1000) + max_pos = db.query(func.max(models.Task.position)).filter(models.Task.assignee_id == task.assignee_id).scalar() or 0.0 + db_task = models.Task( id=task_id, title=task.title, @@ -120,7 +124,8 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db), current source=task.source, status=task.status, due_at=task.due_at, - reminder_at=task.reminder_at + reminder_at=task.reminder_at, + position=max_pos + 1000.0 ) db.add(db_task) diff --git a/backend/models.py b/backend/models.py index 9c83066..a6e1854 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum +from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum, Float from sqlalchemy.orm import relationship from sqlalchemy.sql import func from .database import Base @@ -43,6 +43,7 @@ class Task(Base): due_at = Column(DateTime(timezone=True)) reminder_at = Column(DateTime(timezone=True)) deleted_at = Column(DateTime(timezone=True)) + position = Column(Float, default=0.0) assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id]) tags = relationship("Tag", secondary=task_tags, back_populates="tasks") @@ -90,3 +91,4 @@ class Workspace(Base): id = Column(String, primary_key=True) name = Column(String, nullable=False) timezone = Column(String, nullable=False, default="Pacific/Auckland") + diff --git a/backend/schemas.py b/backend/schemas.py index 6f74bcc..db2058e 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -68,6 +68,7 @@ class TaskBase(BaseModel): due_at: Optional[datetime] = None reminder_at: Optional[datetime] = None deleted_at: Optional[datetime] = None + position: float = 0.0 class TaskCreate(TaskBase): id: Optional[str] = None @@ -81,6 +82,7 @@ class TaskUpdate(BaseModel): status: Optional[str] = None due_at: Optional[datetime] = None reminder_at: Optional[datetime] = None + position: Optional[float] = None class Task(TaskBase): id: str diff --git a/components.jsx b/components.jsx index d47d027..83eb74a 100644 --- a/components.jsx +++ b/components.jsx @@ -97,12 +97,13 @@ function findUser(id) { return USERS.find(u => u.id === id); } -function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd }) { +function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd, onDragOver }) { const author = findUser(task.addedBy); const isAuto = task.status === 'unsuccessful' || task.status === 'billing'; return (
onDragEnd && onDragEnd()} + onDragOver={onDragOver} onClick={() => onOpen && onOpen(task)} data-comment-anchor={"task-" + task.id} > diff --git a/dashy.db b/dashy.db index f071693..eb3f74e 100644 Binary files a/dashy.db and b/dashy.db differ diff --git a/screens.jsx b/screens.jsx index 60700f2..9206ad0 100644 --- a/screens.jsx +++ b/screens.jsx @@ -212,29 +212,59 @@ function HeadsUp({ items, onDismiss, onOpenTask }) { function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers = [] }) { const byUser = Object.fromEntries(dbUsers.map(u => [u.id, []])); tasks.forEach(t => { if (byUser[t.assignee] && t.status !== 'closed') byUser[t.assignee].push(t); }); + const [draggingTask, setDraggingTask] = React.useState(null); const [dragOverCol, setDragOverCol] = React.useState(null); + const [dragOverTaskId, setDragOverTaskId] = React.useState(null); + const [dropSide, setDropSide] = React.useState('bottom'); // 'top' or 'bottom' + return (
{dbUsers.map(u => ( !draggingTask || t.id !== draggingTask.id)} onOpen={onOpen} onAdd={() => onAddFor(u.id)} density={density} dragOver={dragOverCol === u.id && draggingTask && draggingTask.assignee !== u.id} onDragOver={(uid) => setDragOverCol(uid)} - onDragLeave={() => setDragOverCol(prev => prev === u.id ? null : prev)} - onDragStartCard={(t) => setDraggingTask(t)} - onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); }} + onDragLeave={() => { setDragOverCol(null); setDragOverTaskId(null); }} + onDragStartCard={(t) => { + // Use a tiny timeout to ensure the drag image is captured before we hide the element + setTimeout(() => setDraggingTask(t), 0); + }} + onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }} draggingId={draggingTask && draggingTask.id} + dragOverTaskId={dragOverTaskId} + dropSide={dropSide} + onDragOverTask={(tid, side) => { setDragOverTaskId(tid); setDropSide(side); }} onDropTask={(toId) => { - if (draggingTask && draggingTask.assignee !== toId) { - onMoveTask && onMoveTask(draggingTask.id, toId); + if (!draggingTask) return; + + // Calculate position using the list that DOES NOT include the dragging task + const colTasks = byUser[toId].filter(t => t.id !== draggingTask.id); + let newPos = 0; + + if (dragOverTaskId) { + const idx = colTasks.findIndex(t => t.id === dragOverTaskId); + if (dropSide === 'top') { + const prev = colTasks[idx - 1]; + newPos = prev ? (colTasks[idx].position + prev.position) / 2 : colTasks[idx].position / 2; + } else { + const next = colTasks[idx + 1]; + newPos = next ? (colTasks[idx].position + next.position) / 2 : colTasks[idx].position + 1000; + } + } else { + // Dropped on empty area or no specific task + const last = colTasks[colTasks.length - 1]; + newPos = last ? last.position + 1000 : 1000; } - setDraggingTask(null); setDragOverCol(null); + + onMoveTask && onMoveTask(draggingTask.id, toId, newPos); + setDraggingTask(null); setDragOverCol(null); setDragOverTaskId(null); }} /> ))} @@ -242,13 +272,17 @@ function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers ); } -function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onDragOver, onDragLeave, onDragStartCard, onDragEndCard, draggingId }) { +function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onDragOver, onDragLeave, onDragStartCard, onDragEndCard, draggingId, dragOverTaskId, dropSide, onDragOverTask }) { return (
{ e.preventDefault(); onDragOver && onDragOver(user.id); }} - onDragLeave={(e) => { onDragLeave && onDragLeave(user.id); }} + onDragLeave={(e) => { + if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget)) { + onDragLeave && onDragLeave(user.id); + } + }} onDrop={(e) => { e.preventDefault(); onDropTask && onDropTask(user.id); }} >
@@ -266,23 +300,55 @@ function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onD
-
- {tasks.length === 0 && ( + +
+ {tasks.length === 0 && !dragOver && (
— inbox zero —
)} - {tasks.map(t => ( - - ))} + + {tasks.length === 0 && dragOver && ( +
+ )} + + {tasks.map(t => { + const isOver = dragOverTaskId === t.id; + const isTop = isOver && dropSide === 'top'; + const isBottom = isOver && dropSide === 'bottom'; + + return ( +
+ {isOver && ( +
+ )} + { + const rect = e.currentTarget.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + onDragOverTask(t.id, e.clientY < mid ? 'top' : 'bottom'); + }} + /> +
+ ); + })}
); diff --git a/styles.css b/styles.css index d011f08..add780f 100644 --- a/styles.css +++ b/styles.css @@ -356,6 +356,21 @@ input, textarea { font: inherit; color: inherit; } padding: 18px 0; } +.drop-placeholder { + height: 52px; + background: color-mix(in oklch, var(--accent) 6%, var(--bg-sunken)); + border: 1.5px dashed var(--accent-line); + border-radius: var(--radius); + margin: 4px 0; + pointer-events: none; + animation: placeholder-pulse 1.5s ease-in-out infinite; +} + +@keyframes placeholder-pulse { + 0%, 100% { opacity: 0.5; border-color: var(--accent-line); } + 50% { opacity: 1; border-color: var(--accent); } +} + /* === CARD === */ .card { background: var(--bg-elev);