From 0acf9ddff21a4f34f926baecea7756b5058083cd Mon Sep 17 00:00:00 2001 From: indigo Date: Mon, 22 Jun 2026 00:30:18 +0800 Subject: [PATCH] Optimize shot table loading performance - Pass episode_id filter to backend API instead of client-side filtering, reducing payload size when an episode is selected - Skip getShot refetch in ShotDetailPanel when initialShot prop is provided - Fix redundant DB query in list_shots: read project.custom_task_statuses directly from the already-fetched project object - Add missing indexes on Task.shot_id, Task.assigned_user_id, Task.deleted_at and Episode.project_id; add add_perf_indexes.py migration script to apply them to existing databases - Center login page layout - Update CLAUDE.md and AGENTS.md with correct venv path Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 4 +- CLAUDE.md | 6 +- backend/add_perf_indexes.py | 18 ++++++ backend/models/episode.py | 2 +- backend/models/task.py | 6 +- backend/routers/shots.py | 62 +++++++++++++++++- backend/schemas/shot.py | 21 ++++--- frontend/src/components/layout/AppSidebar.vue | 2 +- frontend/src/components/shot/ShotBrowser.vue | 18 +++++- .../src/components/shot/ShotDetailPanel.vue | 3 +- frontend/src/router/index.ts | 2 +- frontend/src/services/breadcrumb.ts | 25 ++++++++ frontend/src/views/auth/LoginView.vue | 63 +------------------ 13 files changed, 151 insertions(+), 81 deletions(-) create mode 100644 backend/add_perf_indexes.py diff --git a/AGENTS.md b/AGENTS.md index b706f0a..a4fa76d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +33,11 @@ LinkDesk is a VFX/animation production management system: FastAPI backend + Vue ### Backend ```bash +# venv lives at repo root, not inside backend/ cd backend -# Windows: .venv\Scripts\activate | bash/mac: source .venv/bin/activate +..\venv\Scripts\activate # Windows PowerShell uvicorn main:app --reload --port 8000 +# or invoke directly: D:\Repo\LinkDesk\.venv\Scripts\uvicorn.exe main:app --reload --port 8000 ``` ### Frontend diff --git a/CLAUDE.md b/CLAUDE.md index fc574fa..5ab062d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,11 @@ LinkDesk is a VFX/animation production management system. It has a FastAPI backe ### Backend ```bash +# venv is at repo root, not inside backend/ cd backend -# Activate venv first (Windows) -.venv\Scripts\activate # or: source .venv/bin/activate on bash +..\venv\Scripts\activate # Windows PowerShell +# or run directly: +# D:\Repo\LinkDesk\.venv\Scripts\uvicorn.exe main:app --reload --port 8000 uvicorn main:app --reload --port 8000 ``` diff --git a/backend/add_perf_indexes.py b/backend/add_perf_indexes.py new file mode 100644 index 0000000..af81989 --- /dev/null +++ b/backend/add_perf_indexes.py @@ -0,0 +1,18 @@ +"""One-time migration: add performance indexes for shot table queries.""" +from sqlalchemy import text +from database import engine + +indexes = [ + "CREATE INDEX IF NOT EXISTS ix_tasks_shot_id ON tasks (shot_id)", + "CREATE INDEX IF NOT EXISTS ix_tasks_assigned_user_id ON tasks (assigned_user_id)", + "CREATE INDEX IF NOT EXISTS ix_tasks_deleted_at ON tasks (deleted_at)", + "CREATE INDEX IF NOT EXISTS ix_episodes_project_id ON episodes (project_id)", +] + +with engine.connect() as conn: + for sql in indexes: + conn.execute(text(sql)) + print(f"OK: {sql}") + conn.commit() + +print("Done.") diff --git a/backend/models/episode.py b/backend/models/episode.py index 46236e8..bf62f71 100644 --- a/backend/models/episode.py +++ b/backend/models/episode.py @@ -17,7 +17,7 @@ class Episode(Base): __tablename__ = "episodes" id = Column(Integer, primary_key=True, index=True) - project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True) name = Column(String, nullable=False, index=True) description = Column(String) episode_number = Column(Integer, nullable=False) diff --git a/backend/models/task.py b/backend/models/task.py index 13fb450..6cb6232 100644 --- a/backend/models/task.py +++ b/backend/models/task.py @@ -44,9 +44,9 @@ class Task(Base): id = Column(Integer, primary_key=True, index=True) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) episode_id = Column(Integer, ForeignKey("episodes.id"), nullable=True) - shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True) + shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True, index=True) asset_id = Column(Integer, ForeignKey("assets.id"), nullable=True) - assigned_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + assigned_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) task_type = Column(String, nullable=False) # Changed from Enum to String to support custom task types name = Column(String, nullable=False, index=True) description = Column(Text) @@ -56,7 +56,7 @@ class Task(Base): updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) # Soft deletion columns - deleted_at = Column(DateTime(timezone=True), nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True, index=True) deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True) # Relationships diff --git a/backend/routers/shots.py b/backend/routers/shots.py index 4b7909a..2b0a442 100644 --- a/backend/routers/shots.py +++ b/backend/routers/shots.py @@ -266,9 +266,17 @@ async def list_shots( ) for project in projects: custom_types = project.custom_shot_task_types or [] + raw = project.custom_task_statuses + if isinstance(raw, str): + try: + import json + raw = json.loads(raw) + except (json.JSONDecodeError, TypeError): + raw = [] + custom_statuses = raw if isinstance(raw, list) else [] project_data[project.id] = { 'task_types': STANDARD_SHOT_TASK_TYPES + custom_types, - 'custom_statuses': get_project_custom_statuses(project.id, db) + 'custom_statuses': custom_statuses } # OPTIMIZATION: Group results by shot and aggregate task data efficiently @@ -606,6 +614,58 @@ async def get_shot( shot_data = ShotResponse.model_validate(shot) shot_data.task_count = task_count + # Add project_name from episode.project (already eager loaded) + if shot.episode and shot.episode.project: + shot_data.project_name = shot.episode.project.name + + # Add task status information (similar to list endpoint) + project = shot.episode.project if shot.episode else None + + # Get all task types: standard + custom + from routers.shots import STANDARD_SHOT_TASK_TYPES + project_task_types = list(STANDARD_SHOT_TASK_TYPES) # Start with standard types + + if project and project.custom_shot_task_types: + custom_types = project.custom_shot_task_types or [] + if isinstance(custom_types, list): + for ct in custom_types: + if isinstance(ct, dict) and 'type' in ct: + project_task_types.append(ct['type']) + elif isinstance(ct, str): + project_task_types.append(ct) + + # Initialize task_status and task_ids dictionaries + task_status_dict = {} + task_ids_dict = {} + task_details_list = [] + + # Initialize with default not_started for all project task types + for task_type_init in project_task_types: + task_status_dict[task_type_init] = "not_started" + + # Build task information from active tasks + for task in active_tasks: + task_type = task.task_type.value if hasattr(task.task_type, 'value') else task.task_type + task_status = task.status.value if hasattr(task.status, 'value') else task.status + task_id = task.id + assigned_user_id = task.assigned_user_id + + # Update task status + task_status_dict[task_type] = task_status + task_ids_dict[task_type] = task_id + + # Add to task details + task_details_list.append(TaskStatusInfo( + task_type=task_type, + status=task_status, + task_id=task_id, + assigned_user_id=assigned_user_id + )) + + shot_data.task_status = task_status_dict + shot_data.task_ids = task_ids_dict + shot_data.task_details = task_details_list + return shot_data diff --git a/backend/schemas/shot.py b/backend/schemas/shot.py index 9194e0f..de0a0d6 100644 --- a/backend/schemas/shot.py +++ b/backend/schemas/shot.py @@ -28,6 +28,14 @@ class ShotUpdate(BaseModel): project_id: Optional[int] = Field(None, description="Project ID - must match episode's project") +class TaskStatusInfo(BaseModel): + """Task status information for table display""" + task_type: str # String to support custom task types + status: str # Changed from TaskStatus enum to str to support custom statuses + task_id: Optional[int] = None + assigned_user_id: Optional[int] = None + + class ShotResponse(ShotBase): id: int project_id: int # Make required in response @@ -40,19 +48,16 @@ class ShotResponse(ShotBase): # Optional computed field for display project_name: Optional[str] = Field(None, description="Project name for display purposes") + + # Task status information for detail display + task_status: Dict[str, Optional[str]] = Field(default_factory=dict, description="Task status by task type") + task_ids: Dict[str, int] = Field(default_factory=dict, description="Task IDs by task type") + task_details: List[TaskStatusInfo] = Field(default_factory=list, description="Detailed task information") class Config: from_attributes = True -class TaskStatusInfo(BaseModel): - """Task status information for table display""" - task_type: str # String to support custom task types - status: str # Changed from TaskStatus enum to str to support custom statuses - task_id: Optional[int] = None - assigned_user_id: Optional[int] = None - - class ShotListResponse(BaseModel): id: int name: str diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 2faf180..fb6bc77 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -138,7 +138,7 @@ const isCollapsed = computed(() => state.value === 'collapsed') // Check if on shot page - show column switch only on shot pages const isOnShotPage = computed(() => { - return route.path.includes('/shots') || route.path.includes('/project/') + return route.path.endsWith('/shots') || route.path.includes('/project/') }) const props = withDefaults(defineProps(), { diff --git a/frontend/src/components/shot/ShotBrowser.vue b/frontend/src/components/shot/ShotBrowser.vue index 8a20f71..f6a9570 100644 --- a/frontend/src/components/shot/ShotBrowser.vue +++ b/frontend/src/components/shot/ShotBrowser.vue @@ -152,6 +152,7 @@ @update:column-visibility="handleColumnVisibilityChange" @update:rowSelection="handleRowSelectionChange" @row-click="handleRowClick" + @row-dblclick="handleRowDoubleClick" /> @@ -172,6 +173,7 @@ import { ref, computed, watch, shallowRef, markRaw, nextTick, onMounted } from 'vue' +import { useRouter } from 'vue-router' import { Search, Plus, Camera, AlertCircle, RefreshCw, Layers, MoreHorizontal, Edit, ListTodo, Trash2 @@ -336,6 +340,7 @@ interface Props { const props = defineProps() // Composables +const router = useRouter() const { toast } = useToast() const taskStatusesStore = useTaskStatusesStore() @@ -355,6 +360,13 @@ const { sessionStorageKey: 'shotBrowser.detailPanelEnabled' }) +// Handle double-click to navigate to shot detail page +const handleRowDoubleClick = (shot: Shot) => { + if (props.projectId) { + router.push(`/projects/${props.projectId}/shots/${shot.id}`) + } +} + // Reactive state const shots = ref([]) const isLoading = ref(false) @@ -430,7 +442,10 @@ const loadShots = async () => { if (taskStatusFilter.value) { options.taskStatusFilter = taskStatusFilter.value } - + if (episodeFilter.value !== null) { + options.episodeId = episodeFilter.value + } + const data = await shotService.getShots(options) shots.value = data } catch (err) { @@ -524,6 +539,7 @@ const clearValidationError = () => { const handleEpisodeFilterChange = (episodeId: number | null) => { episodeFilter.value = episodeId + loadShots() } const handleTaskStatusFilter = (filter: string) => { diff --git a/frontend/src/components/shot/ShotDetailPanel.vue b/frontend/src/components/shot/ShotDetailPanel.vue index b01c8eb..0faf288 100644 --- a/frontend/src/components/shot/ShotDetailPanel.vue +++ b/frontend/src/components/shot/ShotDetailPanel.vue @@ -301,6 +301,7 @@ interface Task extends TaskStatusInfo { interface Props { projectId: number shotId: number + initialShot?: Shot } interface Emits { @@ -387,7 +388,7 @@ const loadShotDetails = async () => { try { isLoading.value = true error.value = null - shot.value = await shotService.getShot(props.shotId) + shot.value = props.initialShot ?? await shotService.getShot(props.shotId) loadTasks() // No longer async - uses embedded data } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to load shot details' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7fb2d78..25c19ae 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -64,7 +64,7 @@ const routes: RouteRecordRaw[] = [ path: 'shots/:shotId', name: 'ShotDetail', component: () => import('@/views/project/ShotDetailView.vue'), - meta: { requiresAuth: true } + meta: { requiresAuth: true, tab: 'shots', tabLabel: 'Shots' } }, { path: 'assets', diff --git a/frontend/src/services/breadcrumb.ts b/frontend/src/services/breadcrumb.ts index e75b3b1..c5062d6 100644 --- a/frontend/src/services/breadcrumb.ts +++ b/frontend/src/services/breadcrumb.ts @@ -1,6 +1,7 @@ import type { RouteLocationNormalized } from 'vue-router' import { useProjectsStore } from '@/stores/projects' import { episodeService } from '@/services/episode' +import { shotService } from '@/services/shot' export interface BreadcrumbItem { label: string @@ -73,6 +74,30 @@ export class BreadcrumbService { }) } } + + // Handle shot detail page (path: /projects/:projectId/shots/:shotId) + if (pathSegments[2] === 'shots' && pathSegments[3]) { + const shotId = parseInt(pathSegments[3]) + if (!isNaN(shotId)) { + try { + const shot = await shotService.getShot(shotId) + // Replace the last item (Shots) with Shots > ShotName + if (crumbs.length > 1) { + crumbs[crumbs.length - 1] = { + label: 'Shots', + href: `/projects/${projectId}/shots` + } + } + crumbs.push({ + label: shot.name, + isActive: true + }) + } catch (error) { + console.error('Failed to load shot for breadcrumbs:', error) + // Keep the default behavior - show shot ID + } + } + } } } else { // Handle other routes diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 939d0bf..ad0f7c1 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -1,7 +1,6 @@