diff --git a/backend/generate_schedule_example_data.py b/backend/generate_schedule_example_data.py
new file mode 100644
index 0000000..92c9faa
--- /dev/null
+++ b/backend/generate_schedule_example_data.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""
+One-off script to generate example start_date/deadline data on shot tasks,
+for testing/demoing the Project Schedule (Gantt) page.
+
+Reuses the same shot-sampling approach as generate_note_example_data.py: the
+dev database is heavily populated with disposable load-test fixtures, so this
+picks a small, bounded sample of shots (from Dragon Quest) that have the
+normal 4-task set, rather than scheduling all ~1400 shot tasks.
+
+For each sampled shot, tasks are scheduled in pipeline order (layout ->
+animation -> simulation -> lighting -> compositing, any other task types
+appended after) with realistic overlapping durations, staggered around
+today's date so the chart's "Today" marker falls inside the visible range.
+
+Idempotent: skips any task that already has a start_date set, so it's safe
+to re-run without clobbering manually-edited dates.
+"""
+
+from datetime import date, timedelta
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from database import DATABASE_URL, Base
+from models.shot import Shot
+from models.task import Task
+from models.project import Project
+import logging
+import random
+
+import models # noqa: F401 - ensures every model is registered on Base.metadata
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+PROJECT_NAME = "Dragon Quest"
+SAMPLE_SIZE = 15
+RNG_SEED = 42
+
+# Canonical pipeline order used to sequence a shot's tasks; anything not
+# listed here keeps its natural (query) order, appended at the end.
+PIPELINE_ORDER = ["layout", "animation", "simulation", "lighting", "compositing", "previz"]
+
+
+def pipeline_index(task_type: str) -> int:
+ return PIPELINE_ORDER.index(task_type) if task_type in PIPELINE_ORDER else len(PIPELINE_ORDER)
+
+
+def generate_schedule_example_data():
+ engine = create_engine(DATABASE_URL)
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+ Base.metadata.create_all(bind=engine)
+
+ db = SessionLocal()
+ rng = random.Random(RNG_SEED)
+ today = date.today()
+
+ try:
+ project = db.query(Project).filter(Project.name == PROJECT_NAME).first()
+ if not project:
+ logger.error(f"Project '{PROJECT_NAME}' not found")
+ return
+ logger.info(f"Project '{PROJECT_NAME}' (id={project.id})")
+
+ # Shots with the normal 4-task set (excludes shots with zero tasks
+ # and the handful of outliers with extra bulk-test tasks piled on).
+ shots = db.query(Shot).filter(
+ Shot.project_id == project.id, Shot.deleted_at.is_(None)
+ ).order_by(Shot.id).all()
+ candidates = [
+ s for s in shots
+ if db.query(Task).filter(Task.shot_id == s.id, Task.deleted_at.is_(None)).count() == 4
+ ]
+ sample_shots = candidates[:SAMPLE_SIZE]
+ logger.info(f"{len(candidates)} candidate shots with a 4-task set, sampling {len(sample_shots)}")
+
+ tasks_scheduled = 0
+ tasks_skipped = 0
+
+ for shot in sample_shots:
+ tasks = db.query(Task).filter(
+ Task.shot_id == shot.id, Task.deleted_at.is_(None)
+ ).all()
+ tasks.sort(key=lambda t: pipeline_index(t.task_type))
+
+ # Stagger each shot's pipeline start across a window that straddles
+ # today, so the demo chart shows a mix of past/current/future work.
+ shot_start = today + timedelta(days=rng.randint(-20, 20))
+ cursor = shot_start
+
+ for task in tasks:
+ if task.start_date is not None:
+ tasks_skipped += 1
+ # Still advance the cursor so later tasks in this shot
+ # don't bunch up if an earlier one was already scheduled.
+ cursor = task.deadline or cursor
+ continue
+
+ duration = rng.randint(4, 12)
+ task.start_date = cursor
+ task.deadline = cursor + timedelta(days=duration)
+ tasks_scheduled += 1
+
+ # Next task starts a little before this one ends (realistic
+ # pipeline overlap) rather than strictly back-to-back.
+ cursor = task.deadline - timedelta(days=rng.randint(0, 4))
+
+ db.commit()
+ logger.info(f"Tasks scheduled: {tasks_scheduled}, skipped (already scheduled): {tasks_skipped}")
+
+ except Exception as e:
+ logger.error(f"Generation failed: {e}")
+ db.rollback()
+ raise
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ logger.info("Generating schedule example data...")
+ generate_schedule_example_data()
+ logger.info("Done!")
diff --git a/backend/migrate_task_start_date.py b/backend/migrate_task_start_date.py
new file mode 100644
index 0000000..3080d90
--- /dev/null
+++ b/backend/migrate_task_start_date.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""
+Migration script to add the start_date column to the tasks table.
+
+Usage:
+ python migrate_task_start_date.py
+"""
+
+import sqlite3
+import sys
+from pathlib import Path
+
+
+def get_database_path():
+ """Get the database path, trying multiple possible locations."""
+ possible_paths = [
+ "vfx_project_management.db", # Primary database
+ "database.db",
+ "../vfx_project_management.db"
+ ]
+
+ for path in possible_paths:
+ if Path(path).exists():
+ return path
+
+ return "vfx_project_management.db"
+
+
+def check_column_exists(cursor, table_name, column_name):
+ """Check if a column exists in a table."""
+ cursor.execute(f"PRAGMA table_info({table_name})")
+ columns = [column[1] for column in cursor.fetchall()]
+ return column_name in columns
+
+
+def migrate_database():
+ """Add start_date column to the tasks table."""
+ db_path = get_database_path()
+ print(f"Using database: {db_path}")
+
+ conn = None
+ try:
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
+ if not cursor.fetchone():
+ print("Tasks table not found. Creating new database schema...")
+ conn.close()
+ return
+
+ if check_column_exists(cursor, "tasks", "start_date"):
+ print("Column start_date already exists, skipping...")
+ else:
+ print("Adding column: start_date")
+ cursor.execute("ALTER TABLE tasks ADD COLUMN start_date DATE")
+
+ conn.commit()
+
+ cursor.execute("SELECT COUNT(*) FROM tasks")
+ task_count = cursor.fetchone()[0]
+ print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
+
+ except sqlite3.Error as e:
+ print(f"Database error: {e}")
+ if conn:
+ conn.rollback()
+ sys.exit(1)
+ except Exception as e:
+ print(f"Unexpected error: {e}")
+ if conn:
+ conn.rollback()
+ sys.exit(1)
+ finally:
+ if conn:
+ conn.close()
+
+
+if __name__ == "__main__":
+ print("VFX Project Management - Task Start Date Migration")
+ print("=" * 60)
+
+ migrate_database()
+
+ print("\nMigration completed successfully!")
diff --git a/backend/models/task.py b/backend/models/task.py
index 05d0b82..4903bff 100644
--- a/backend/models/task.py
+++ b/backend/models/task.py
@@ -56,6 +56,7 @@ class Task(Base):
name = Column(String, nullable=False, index=True)
description = Column(Text)
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
+ start_date = Column(Date)
deadline = Column(Date)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py
index 7d33057..84e94cc 100644
--- a/backend/routers/tasks.py
+++ b/backend/routers/tasks.py
@@ -248,6 +248,7 @@ async def get_tasks(
"name": task.name,
"task_type": task.task_type,
"status": task.status,
+ "start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"project_name": task.project.name if task.project else None,
@@ -329,6 +330,7 @@ async def get_my_tasks(
name=task.name,
task_type=task.task_type,
status=task.status,
+ start_date=task.start_date,
deadline=task.deadline,
project_id=task.project_id,
project_name=task.project.name if task.project else "Unknown",
@@ -718,6 +720,7 @@ async def get_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -843,6 +846,7 @@ async def update_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -934,6 +938,7 @@ async def update_task_status(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
@@ -1039,6 +1044,7 @@ async def assign_task(
"description": task.description,
"task_type": task.task_type,
"status": task.status,
+ "start_date": task.start_date,
"deadline": task.deadline,
"project_id": task.project_id,
"episode_id": task.episode_id,
diff --git a/backend/schemas/task.py b/backend/schemas/task.py
index 8ced5bc..b823e8e 100644
--- a/backend/schemas/task.py
+++ b/backend/schemas/task.py
@@ -11,6 +11,7 @@ class TaskBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
task_type: str # Changed from TaskType enum to str to support custom task types
+ start_date: Optional[date] = None
deadline: Optional[date] = None
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
@@ -27,6 +28,7 @@ class TaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types
+ start_date: Optional[date] = None
deadline: Optional[date] = None
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses
assigned_user_id: Optional[int] = None
@@ -68,6 +70,7 @@ class TaskListResponse(BaseModel):
name: str
task_type: str # Changed from TaskType enum to str to support custom task types
status: str # Changed from TaskStatus enum to str to support custom statuses
+ start_date: Optional[date] = None
deadline: Optional[date] = None
project_id: int
project_name: str
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index db269ef..a6fda50 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -182,6 +182,7 @@ import {
Package,
ListTodo,
ShieldCheck,
+ GanttChartSquare,
} from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
@@ -221,6 +222,7 @@ const projectTabs = computed(() => {
{ id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` },
{ id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` },
{ id: 'tasks', label: 'Tasks', icon: ListTodo, route: `/projects/${id}/tasks` },
+ { id: 'schedule', label: 'Schedule', icon: GanttChartSquare, route: `/projects/${id}/schedule` },
{ id: 'settings', label: 'Settings', icon: Settings, route: `/projects/${id}/settings` },
]
})
@@ -233,6 +235,7 @@ const activeProjectTab = computed(() => {
if (path.startsWith(`/projects/${id}/shots`)) return 'shots'
if (path.startsWith(`/projects/${id}/assets`)) return 'assets'
if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks'
+ if (path.startsWith(`/projects/${id}/schedule`)) return 'schedule'
if (path.startsWith(`/projects/${id}/settings`)) return 'settings'
return null
})
diff --git a/frontend/src/components/schedule/ScheduleGantt.vue b/frontend/src/components/schedule/ScheduleGantt.vue
new file mode 100644
index 0000000..68c98f7
--- /dev/null
+++ b/frontend/src/components/schedule/ScheduleGantt.vue
@@ -0,0 +1,639 @@
+
+
-