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]}
+