diff --git a/backend/alembic/versions/012_add_project_is_tracked.py b/backend/alembic/versions/012_add_project_is_tracked.py new file mode 100644 index 0000000..f762789 --- /dev/null +++ b/backend/alembic/versions/012_add_project_is_tracked.py @@ -0,0 +1,24 @@ +"""add is_tracked to projects + +Revision ID: 012 +Revises: 011 +Create Date: 2026-02-23 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "012" +down_revision = "011" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("projects", sa.Column("is_tracked", sa.Boolean(), server_default=sa.false(), nullable=False)) + + +def downgrade() -> None: + op.drop_column("projects", "is_tracked") diff --git a/backend/app/models/project.py b/backend/app/models/project.py index b79b8f9..1ebdbce 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Date, func +from sqlalchemy import Boolean, String, Text, Date, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List @@ -14,6 +14,7 @@ class Project(Base): status: Mapped[str] = mapped_column(String(20), default="not_started") color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + is_tracked: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 37f9d54..f0a89f4 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -1,15 +1,16 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload -from typing import List +from typing import List, Optional +from datetime import date, timedelta from pydantic import BaseModel from app.database import get_db from app.models.project import Project from app.models.project_task import ProjectTask from app.models.task_comment import TaskComment -from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse +from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse from app.routers.auth import get_current_session @@ -43,17 +44,64 @@ def _task_load_options(): @router.get("/", response_model=List[ProjectResponse]) async def get_projects( + tracked: Optional[bool] = Query(None), db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Get all projects with their tasks.""" + """Get all projects with their tasks. Optionally filter by tracked status.""" query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) + if tracked is not None: + query = query.where(Project.is_tracked == tracked) result = await db.execute(query) projects = result.scalars().unique().all() return projects +@router.get("/tracked-tasks", response_model=List[TrackedTaskResponse]) +async def get_tracked_tasks( + days: int = Query(7, ge=1, le=90), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get tasks and subtasks from tracked projects with due dates within the next N days.""" + today = date.today() + cutoff = today + timedelta(days=days) + + query = ( + select(ProjectTask) + .join(Project, ProjectTask.project_id == Project.id) + .options( + selectinload(ProjectTask.project), + selectinload(ProjectTask.parent_task), + ) + .where( + Project.is_tracked == True, + ProjectTask.due_date.isnot(None), + ProjectTask.due_date >= today, + ProjectTask.due_date <= cutoff, + ProjectTask.status != "completed", + ) + .order_by(ProjectTask.due_date.asc()) + ) + result = await db.execute(query) + tasks = result.scalars().unique().all() + + return [ + TrackedTaskResponse( + id=t.id, + title=t.title, + status=t.status, + priority=t.priority, + due_date=t.due_date, + project_name=t.project.name, + project_id=t.project_id, + parent_task_title=t.parent_task.title if t.parent_task else None, + ) + for t in tasks + ] + + @router.post("/", response_model=ProjectResponse, status_code=201) async def create_project( project: ProjectCreate, diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 650a4eb..d307f8c 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -12,6 +12,7 @@ class ProjectCreate(BaseModel): status: ProjectStatus = "not_started" color: Optional[str] = None due_date: Optional[date] = None + is_tracked: bool = False class ProjectUpdate(BaseModel): @@ -20,6 +21,7 @@ class ProjectUpdate(BaseModel): status: Optional[ProjectStatus] = None color: Optional[str] = None due_date: Optional[date] = None + is_tracked: Optional[bool] = None class ProjectResponse(BaseModel): @@ -29,8 +31,22 @@ class ProjectResponse(BaseModel): status: str color: Optional[str] due_date: Optional[date] + is_tracked: bool created_at: datetime updated_at: datetime tasks: List[ProjectTaskResponse] = [] model_config = ConfigDict(from_attributes=True) + + +class TrackedTaskResponse(BaseModel): + id: int + title: str + status: str + priority: str + due_date: date + project_name: str + project_id: int + parent_task_title: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 8dc70f7..ee10080 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -12,6 +12,7 @@ import UpcomingWidget from './UpcomingWidget'; import WeekTimeline from './WeekTimeline'; import DayBriefing from './DayBriefing'; import CountdownWidget from './CountdownWidget'; +import TrackedProjectsWidget from './TrackedProjectsWidget'; import EventForm from '../calendar/EventForm'; import TodoForm from '../todos/TodoForm'; import ReminderForm from '../reminders/ReminderForm'; @@ -236,6 +237,11 @@ export default function DashboardPage() { )} + + {/* Tracked Projects */} +
+ +
diff --git a/frontend/src/components/dashboard/TrackedProjectsWidget.tsx b/frontend/src/components/dashboard/TrackedProjectsWidget.tsx new file mode 100644 index 0000000..533da58 --- /dev/null +++ b/frontend/src/components/dashboard/TrackedProjectsWidget.tsx @@ -0,0 +1,109 @@ +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { format, parseISO, isPast, isToday } from 'date-fns'; +import { FolderKanban } from 'lucide-react'; +import api from '@/lib/api'; +import type { TrackedTask } from '@/types'; +import { useSettings } from '@/hooks/useSettings'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; + +const priorityColors: Record = { + high: 'bg-red-400', + medium: 'bg-yellow-400', + low: 'bg-green-400', + none: 'bg-gray-400', +}; + +const statusBadgeColors: Record = { + pending: 'bg-gray-500/10 text-gray-400', + in_progress: 'bg-purple-500/10 text-purple-400', + blocked: 'bg-red-500/10 text-red-400', + review: 'bg-yellow-500/10 text-yellow-400', + on_hold: 'bg-orange-500/10 text-orange-400', +}; + +const statusLabels: Record = { + pending: 'Pending', + in_progress: 'Active', + blocked: 'Blocked', + review: 'Review', + on_hold: 'Hold', +}; + +export default function TrackedProjectsWidget() { + const navigate = useNavigate(); + const { settings } = useSettings(); + const days = settings?.upcoming_days || 7; + + const { data: tasks } = useQuery({ + queryKey: ['tracked-tasks', days], + queryFn: async () => { + const { data } = await api.get(`/projects/tracked-tasks?days=${days}`); + return data; + }, + }); + + if (!tasks || tasks.length === 0) return null; + + return ( + + +
+ +
+ +
+ Tracked Projects +
+ {days} days +
+
+ + +
+ {tasks.map((task) => { + const dueDate = parseISO(task.due_date); + const overdue = isPast(dueDate) && !isToday(dueDate); + + return ( +
navigate(`/projects/${task.project_id}`)} + > +
+
+ + {task.parent_task_title && ( + + )} + {task.title} + + {task.parent_task_title && ( + {task.parent_task_title} + )} +
+ {task.project_name} + + {isToday(dueDate) ? 'Today' : format(dueDate, 'MMM d')} + + + {statusLabels[task.status] || task.status} + +
+ ); + })} +
+ + + + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index da1e897..ecd8301 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,4 +1,6 @@ -import { NavLink, useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import { NavLink, useNavigate, useLocation } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { LayoutDashboard, CheckSquare, @@ -10,19 +12,21 @@ import { Settings, ChevronLeft, ChevronRight, + ChevronDown, X, LogOut, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; import { Button } from '@/components/ui/button'; +import api from '@/lib/api'; +import type { Project } from '@/types'; const navItems = [ { to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/todos', icon: CheckSquare, label: 'Todos' }, { to: '/calendar', icon: Calendar, label: 'Calendar' }, { to: '/reminders', icon: Bell, label: 'Reminders' }, - { to: '/projects', icon: FolderKanban, label: 'Projects' }, { to: '/people', icon: Users, label: 'People' }, { to: '/locations', icon: MapPin, label: 'Locations' }, ]; @@ -36,13 +40,26 @@ interface SidebarProps { export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { const navigate = useNavigate(); + const location = useLocation(); const { logout } = useAuth(); + const [projectsExpanded, setProjectsExpanded] = useState(false); + + const { data: trackedProjects } = useQuery({ + queryKey: ['projects', 'tracked'], + queryFn: async () => { + const { data } = await api.get('/projects?tracked=true'); + return data; + }, + }); const handleLogout = async () => { await logout(); navigate('/login'); }; + const isProjectsActive = location.pathname.startsWith('/projects'); + const showExpanded = !collapsed || mobileOpen; + const navLinkClass = ({ isActive }: { isActive: boolean }) => cn( 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200', @@ -51,6 +68,80 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose : 'text-muted-foreground hover:bg-accent/10 hover:text-accent border-l-2 border-transparent' ); + const projectsSection = ( +
+
+ { + navigate('/projects'); + if (mobileOpen) onMobileClose(); + }} + /> + {showExpanded && ( + <> + { + navigate('/projects'); + if (mobileOpen) onMobileClose(); + }} + > + Projects + + {trackedProjects && trackedProjects.length > 0 && ( + + )} + + )} +
+ {showExpanded && projectsExpanded && trackedProjects && trackedProjects.length > 0 && ( +
+ {trackedProjects.map((project) => { + const isActive = location.pathname === `/projects/${project.id}`; + return ( + + ); + })} +
+ )} +
+ ); + const sidebarContent = ( <>
@@ -80,9 +171,10 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose className={navLinkClass} > - {(!collapsed || mobileOpen) && {item.label}} + {showExpanded && {item.label}} ))} + {projectsSection}
@@ -92,14 +184,14 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose className={navLinkClass} > - {(!collapsed || mobileOpen) && Settings} + {showExpanded && Settings}
diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index feee433..4d79001 100644 --- a/frontend/src/components/projects/ProjectCard.tsx +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -1,6 +1,9 @@ import { useNavigate } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { format, isPast, parseISO } from 'date-fns'; -import { Calendar } from 'lucide-react'; +import { Calendar, Pin } from 'lucide-react'; +import api from '@/lib/api'; import type { Project } from '@/types'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -30,6 +33,21 @@ const statusLabels: Record = { export default function ProjectCard({ project }: ProjectCardProps) { const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const toggleTrackMutation = useMutation({ + mutationFn: async () => { + const { data } = await api.put(`/projects/${project.id}`, { is_tracked: !project.is_tracked }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }); + toast.success(project.is_tracked ? 'Project untracked' : 'Project tracked'); + }, + onError: () => { + toast.error('Failed to update tracking'); + }, + }); const completedTasks = project.tasks?.filter((t) => t.status === 'completed').length || 0; const totalTasks = project.tasks?.length || 0; @@ -42,11 +60,25 @@ export default function ProjectCard({ project }: ProjectCardProps) { return ( navigate(`/projects/${project.id}`)} > + -
+
{project.name} {statusLabels[project.status]} diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index ab055a3..bae2055 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -20,7 +20,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { - ArrowLeft, Plus, Trash2, ListChecks, Pencil, + ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin, Calendar, CheckCircle2, PlayCircle, AlertTriangle, List, Columns3, ArrowUpDown, } from 'lucide-react'; @@ -188,6 +188,21 @@ export default function ProjectDetail() { }, }); + const toggleTrackMutation = useMutation({ + mutationFn: async () => { + const { data } = await api.put(`/projects/${id}`, { is_tracked: !project?.is_tracked }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }); + queryClient.invalidateQueries({ queryKey: ['projects', id] }); + toast.success(project?.is_tracked ? 'Project untracked' : 'Project tracked'); + }, + onError: () => { + toast.error('Failed to update tracking'); + }, + }); + const reorderMutation = useMutation({ mutationFn: async (items: { id: number; sort_order: number }[]) => { await api.put(`/projects/${id}/tasks/reorder`, items); @@ -386,6 +401,16 @@ export default function ProjectDetail() { {statusLabels[project.status]} +