diff --git a/backend/controllers/feed.controller.js b/backend/controllers/feed.controller.js index cf7b510..c2d4094 100644 --- a/backend/controllers/feed.controller.js +++ b/backend/controllers/feed.controller.js @@ -3,113 +3,70 @@ import supabase from "../config/db.js"; const manualItems = []; -function calculateEngagementScore( - item -) { - let score = 0; - - if (item.type === "milestone") { - score += 50; - } +/* ----------------------------- SCORING ENGINE ---------------------------- */ - if (item.type === "code") { - score += 30; - } - - if (item.type === "discussion") { - score += 20; - } +function calculateEngagementScore(item) { + let score = 0; - if (item.progress) { - score += item.progress; - } + if (item.type === "milestone") score += 50; + else if (item.type === "code") score += 30; + else if (item.type === "discussion") score += 20; - return score; + return score + (item.progress || 0); } -function calculateRecencyScore( - createdAt -) { - const ageHours = - Math.max( - 1, - (Date.now() - - new Date( - createdAt - ).getTime()) / - 3600000 - ); - - return Math.max( +function calculateRecencyScore(createdAt) { + const ageHours = Math.max( 1, - Math.round(100 / ageHours) + (Date.now() - new Date(createdAt).getTime()) / 3600000 ); + + return Math.max(1, Math.round(100 / ageHours)); } -function buildRankingMetadata( - item -) { - const engagement = - calculateEngagementScore( - item - ); +/* ---------------------------- METADATA BUILDERS -------------------------- */ - const recency = - calculateRecencyScore( - item.createdAt - ); +function buildRankingMetadata(item) { + const engagement = calculateEngagementScore(item); + const recency = calculateRecencyScore(item.createdAt); return { ...item, - rankingScore: - engagement + recency, - rankingFactors: { - engagement, - recency, - }, + rankingScore: engagement + recency, + rankingFactors: { engagement, recency }, }; } -function buildStableRankingMetadata( - item, - index -) { +function buildStableRankingMetadata(item, index) { return { stableRankKey: `${item.id}-${index}`, insertionOrder: index, - refreshSequence: - Date.now(), + refreshSequence: Date.now(), }; } -function buildFeedMetadata( - items = [] -) { +function buildFeedMetadata(items = []) { return { - totalItems: - items.length, + totalItems: items.length, stableSortingEnabled: true, refreshSafeOrdering: true, - generatedAt: - Date.now(), + generatedAt: Date.now(), }; } - +/* ------------------------------ TIME HELPERS ----------------------------- */ function toRelativeTime(value) { if (!value) return "Just now"; - const createdAt = new Date(value).getTime(); const diffMinutes = Math.max( 1, - Math.floor((Date.now() - createdAt) / 60000) + Math.floor((Date.now() - new Date(value).getTime()) / 60000) ); if (diffMinutes < 60) return `${diffMinutes} min ago`; const diffHours = Math.floor(diffMinutes / 60); - if (diffHours < 24) return `${diffHours} hours ago`; return new Date(value).toLocaleDateString("en", { @@ -122,35 +79,32 @@ function groupForDate(value) { if (!value) return "Today"; const itemDate = new Date(value); - const today = new Date(); - - const yesterday = new Date(); + const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); - if (itemDate.toDateString() === today.toDateString()) { - return "Today"; - } - - if (itemDate.toDateString() === yesterday.toDateString()) { - return "Yesterday"; - } + if (itemDate.toDateString() === today.toDateString()) return "Today"; + if (itemDate.toDateString() === yesterday.toDateString()) return "Yesterday"; return "Earlier"; } -function normalizeFeedItem({ - id, - type, - actor, - action, - title, - body, - createdAt, - meta, - image = null, - progress = null, -}) { +/* --------------------------- NORMALIZATION LAYER ------------------------- */ + +function normalizeFeedItem(item) { + const { + id, + type, + actor, + action, + title, + body, + createdAt, + meta, + image = null, + progress = null, + } = item; + return { id, type, @@ -167,6 +121,8 @@ function normalizeFeedItem({ }; } +/* ---------------------------- TRANSFORMERS ------------------------------- */ + function taskToFeedItem(task) { const isDone = task.status === "done"; @@ -178,17 +134,10 @@ function taskToFeedItem(task) { title: task.title || "Untitled task", body: task.description || - `Status changed to ${String( - task.status || "todo" - ).replace("_", " ")}.`, + `Status changed to ${String(task.status || "todo").replace("_", " ")}.`, createdAt: task.created_at, meta: isDone ? "Task completed" : "Task activity", - progress: - isDone - ? 100 - : task.status === "in_progress" - ? 65 - : 20, + progress: isDone ? 100 : task.status === "in_progress" ? 65 : 20, }); } @@ -202,151 +151,82 @@ function messageToFeedItem(message) { body: message.text || "Shared an attachment.", createdAt: message.created_at, meta: "Chat activity", - image: - "https://i.pravatar.cc/96?u=" + - encodeURIComponent( - message.username || message.id - ), + image: `https://i.pravatar.cc/96?u=${encodeURIComponent( + message.username || message.id + )}`, }); } +/* ------------------------------ CORE ENGINE ------------------------------ */ + function buildFeedItems(tasks = [], messages = []) { return [ ...manualItems, - ...(tasks || []).map(taskToFeedItem), - ...(messages || []).map(messageToFeedItem), + ...tasks.map(taskToFeedItem), + ...messages.map(messageToFeedItem), ]; } function sortFeedItems(items = []) { return items - .map( - ( - item, - index - ) => ({ - ...buildRankingMetadata( - item - ), - ...buildStableRankingMetadata( - item, - index - ), - }) - ) + .map((item, index) => ({ + ...buildRankingMetadata(item), + ...buildStableRankingMetadata(item, index), + })) .sort((a, b) => { - if ( - b.rankingScore !== - a.rankingScore - ) { - return ( - b.rankingScore - - a.rankingScore - ); + if (b.rankingScore !== a.rankingScore) { + return b.rankingScore - a.rankingScore; } - const aTime = - Date.parse( - a.createdAt || "" - ) || 0; - - const bTime = - Date.parse( - b.createdAt || "" - ) || 0; + const timeA = Date.parse(a.createdAt || "") || 0; + const timeB = Date.parse(b.createdAt || "") || 0; - if (bTime !== aTime) { - return bTime - aTime; - } + if (timeB !== timeA) return timeB - timeA; - return ( - a.insertionOrder - - b.insertionOrder - ); + return a.insertionOrder - b.insertionOrder; }); } +/* ------------------------------ CONTROLLERS ------------------------------ */ + export const getFeedItems = async (req, res) => { const page = Number(req.query.page || 1); const limit = Number(req.query.limit || 20); const offset = (page - 1) * limit; try { - const [ - { data: tasks, error: tasksError }, - { data: messages, error: messagesError }, - ] = await Promise.all([ + const [{ data: tasks }, { data: messages }] = await Promise.all([ supabase .from("tasks") - .select( - "id,title,description,status,created_at" - ) - .order("created_at", { - ascending: false, - }) + .select("id,title,description,status,created_at") + .order("created_at", { ascending: false }) .limit(50), supabase .from("messages") - .select( - "id,username,text,created_at" - ) - .order("created_at", { - ascending: false, - }) + .select("id,username,text,created_at") + .order("created_at", { ascending: false }) .limit(50), ]); - if (tasksError) throw tasksError; - if (messagesError) throw messagesError; - - const aggregatedItems = - sortFeedItems( - buildFeedItems( - tasks, - messages - ) - ); - - const refreshCycleMetadata = { - refreshEvaluatedAt: - Date.now(), - stableOrdering: - true, - rapidInsertionSafe: - true, - }; - - const feedMetadata = - buildFeedMetadata( - aggregatedItems - ); - - const items = aggregatedItems.slice( - offset, - offset + limit + const aggregatedItems = sortFeedItems( + buildFeedItems(tasks || [], messages || []) ); + const paginatedItems = aggregatedItems.slice(offset, offset + limit); + res.status(200).json({ - metadata: { - ...feedMetadata, - ...refreshCycleMetadata, - }, - items, + metadata: buildFeedMetadata(aggregatedItems), + items: paginatedItems, pagination: { page, limit, total: aggregatedItems.length, - hasMore: - offset + limit < - aggregatedItems.length, + hasMore: offset + limit < aggregatedItems.length, }, }); } catch (error) { - console.error( - "Error loading feed:", - error - ); + console.error("Error loading feed:", error); res.status(500).json({ error: "Failed to load activity feed", @@ -354,15 +234,8 @@ export const getFeedItems = async (req, res) => { } }; -export const createFeedItem = async ( - req, - res -) => { - const { - title, - body, - type = "discussion", - } = req.body || {}; +export const createFeedItem = async (req, res) => { + const { title, body, type = "discussion" } = req.body || {}; if (!title || !body) { return res.status(400).json({ @@ -383,9 +256,7 @@ export const createFeedItem = async ( manualItems.unshift(item); - req.app - .get("io") - ?.emit("feed-created", item); + req.app.get("io")?.emit("feed-created", item); res.status(201).json({ item }); }; \ No newline at end of file diff --git a/frontend/app/components/kanban/KanbanBoard.tsx b/frontend/app/components/kanban/KanbanBoard.tsx index a23a94b..d87f512 100644 --- a/frontend/app/components/kanban/KanbanBoard.tsx +++ b/frontend/app/components/kanban/KanbanBoard.tsx @@ -16,711 +16,295 @@ import { type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; + import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { KanbanColumn } from "./KanbanColumn"; import { KanbanCard } from "./KanbanCard"; import { io } from "socket.io-client"; +/* ---------------- TYPES ---------------- */ + +export type TaskStatus = "todo" | "in_progress" | "done"; + +export type Column = { + id: TaskStatus; + title: string; +}; + export type Task = { id: string; title: string; description: string; - status: "todo" | "in_progress" | "done"; + status: TaskStatus; position: number; project_id: string; }; -const COLUMNS = [ +/* ---------------- CONSTANTS ---------------- */ + +export const COLUMNS: Column[] = [ { id: "todo", title: "To Do" }, { id: "in_progress", title: "In Progress" }, { id: "done", title: "Done" }, -] as const; - -const MAX_RENDERED_TASKS = 500; - -function createRenderMetrics() { - return { - renderVersion: 1, - optimizedUpdates: 0, - skippedUpdates: 0, - }; -} - -function shouldSkipTaskUpdate( - previousTasks: Task[], - nextTasks: Task[] -) { - if ( - previousTasks.length !== - nextTasks.length - ) { - return false; - } - - return previousTasks.every( - (task, index) => - task.id === nextTasks[index]?.id && - task.status === - nextTasks[index]?.status && - task.position === - nextTasks[index]?.position - ); -} - -function buildVisibleTaskSet( - tasks: Task[] -) { - return tasks.slice( - 0, - MAX_RENDERED_TASKS - ); -} - -function optimizeTaskCollection( - tasks: Task[] -) { - return tasks.sort( - (a, b) => { - if ( - a.status === b.status - ) { - return ( - a.position - - b.position - ); - } - - return a.status.localeCompare( - b.status - ); - } - ); -} - -function createUpdateBuffer() { - return new Map< - string, - Task - >(); -} +]; - -type TaskStatus = Task["status"]; +/* ---------------- TYPE GUARD ---------------- */ function isTaskStatus(value: unknown): value is TaskStatus { - return COLUMNS.some((column) => column.id === value); + return value === "todo" || value === "in_progress" || value === "done"; } +/* ---------------- COMPONENT ---------------- */ + export function KanbanBoard() { const [tasks, setTasks] = useState([]); const [activeTask, setActiveTask] = useState(null); + const socketRef = useRef | null>(null); const isSyncingRef = useRef(false); + const isDraggingRef = useRef(false); - const dragOperationRef = useRef(null); const previousTasksRef = useRef([]); + const dragOperationRef = useRef(null); const lastSocketUpdateRef = useRef>(new Map()); - const isDraggingRef = useRef(false); - - const updateBufferRef = - useRef( - createUpdateBuffer() - ); - - const renderBatchRef = - useRef(0); - - const renderStartRef = - useRef(Date.now()); - - const [renderMetrics, setRenderMetrics] = - useState( - createRenderMetrics() - ); - const handleEditTask = async (task: Task) => { - const newTitle = window.prompt("Edit title:", task.title); - if (!newTitle) return; - - const newDescription = window.prompt( - "Edit description:", - task.description - ); - - try { - const session = await supabase?.auth.getSession(); - - const token = session?.data.session?.access_token; - await fetch( - `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks/${task.id}/edit`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - title: newTitle, - description: newDescription, - }), - } - ); - } catch (error) { - console.error("Failed to update task", error); - } -}; + /* ---------------- FETCH ---------------- */ const fetchTasks = async () => { try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks`); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks` + ); + if (res.ok) { const data = await res.json(); - setTasks( - optimizeTaskCollection( - buildVisibleTaskSet(data) - ) - ); + setTasks(Array.isArray(data) ? data : data?.tasks || []); } - } catch (error) { - console.error("Failed to fetch tasks", error); + } catch (err) { + console.error(err); } }; - useEffect(() => { - // Assuming backend runs on 5000 in dev - const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; - const newSocket = io(apiUrl); - socketRef.current = newSocket; - newSocket.emit("join", { - username: "kanban-user", - room: "project-alpha", - }); + /* ---------------- HELPERS ---------------- */ - const loadTimer = window.setTimeout(() => { - void fetchTasks(); - }, 0); + const isDuplicateRealtimeUpdate = (task: Task) => { + const now = Date.now(); + const last = lastSocketUpdateRef.current.get(task.id) ?? 0; - newSocket.on("task-moved", (movedTask: Task) => { + if (now - last < 500) return true; - if (isDraggingRef.current) { - return; - } - if ( - isSyncingRef.current || - isDuplicateRealtimeUpdate(movedTask) - ) { - return; - } - - updateBufferRef.current.set( - movedTask.id, - movedTask - ); - - setTasks((prev) => { - const updatedTasks = prev.some( - (task) => task.id === movedTask.id - ) - ? prev.map((task) => - task.id === movedTask.id - ? movedTask - : task - ) - : [...prev, movedTask]; - - if ( - shouldSkipTaskUpdate( - prev, - updatedTasks - ) - ) { - setRenderMetrics( - (current) => ({ - ...current, - skippedUpdates: - current.skippedUpdates + 1, - }) - ); - - return prev; - } - - const normalized = - normalizeColumnPositions( - updatedTasks - ); - - setRenderMetrics( - (current) => ({ - ...current, - optimizedUpdates: - current.optimizedUpdates + 1, - }) - ); - - return normalized; - }); - }); - newSocket.on("task-created", (newTask: Task) => { - if (isDraggingRef.current) return; - setTasks((prev) => - [...prev, newTask].sort( - (a, b) => a.position - b.position - ) - ); - }); - - newSocket.on("task-updated", (updatedTask: Task) => { - - if (isDraggingRef.current) return; - - setTasks((prev) => - prev.map((t) => - t.id === updatedTask.id - ? updatedTask - : t - ) - ); - }); - - newSocket.on("task-deleted", (taskId: string) => { - if (isDraggingRef.current) return; - - setTasks((prev) => - prev.filter((t) => t.id !== taskId) - ); - }); - - return () => { - window.clearTimeout(loadTimer); - newSocket.disconnect(); - socketRef.current = null; - }; - }, []); + lastSocketUpdateRef.current.set(task.id, now); + return false; + }; + const snapshotTasks = (tasks: Task[]) => + tasks.map((t) => ({ ...t })); - useEffect(() => { - const timer = - window.setInterval(() => { - if ( - updateBufferRef.current - .size === 0 - ) { - return; - } + const rollback = () => setTasks(previousTasksRef.current); - updateBufferRef.current.clear(); - - setRenderMetrics( - (current) => ({ - ...current, - renderVersion: - current.renderVersion + - 1, - }) - ); - }, 1000); - - return () => - window.clearInterval( - timer - ); - }, []); + const normalizeColumnPositions = (list: Task[]) => { + const result: Task[] = []; - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 5, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); + for (const column of COLUMNS) { + const columnTasks = list + .filter((t) => t.status === column.id) + .sort((a, b) => a.position - b.position); - const handleDragStart = ( - event: DragStartEvent - ) => { - if (dragOperationRef.current) { - return; + columnTasks.forEach((task, index) => { + result.push({ ...task, position: index }); + }); } - dragOperationRef.current = String( - event.active.id - ); - - isDraggingRef.current = true; - - previousTasksRef.current = - createTaskSnapshot(tasks); - - const task = tasks.find( - (t) => t.id === event.active.id - ); - - if (task) { - setActiveTask(task); - } + return result; }; - const handleDragOver = (event: DragOverEvent) => { - const { active, over } = event; - - if (!over) return; + /* ---------------- HANDLERS (FIXED) ---------------- */ - const activeId = active.id; - const overId = over.id; - - if (activeId === overId) return; - - const isActiveTask = active.data.current?.type === "Task"; - const isOverTask = over.data.current?.type === "Task"; + const handleCreateTask = async (columnId: string) => { + const title = window.prompt("Task Title:"); + if (!title) return; - if (!isActiveTask) return; + const newTask = { + title, + description: "", + status: columnId, + project_id: "default", + position: tasks.filter((t) => t.status === columnId).length, + }; - // Dropping a Task over another Task - if (isActiveTask && isOverTask) { - setTasks((tasks) => { - const activeIndex = tasks.findIndex((t) => t.id === activeId); - const overIndex = tasks.findIndex((t) => t.id === overId); + try { + const session = await supabase?.auth.getSession(); + const token = session?.data.session?.access_token; - if (tasks[activeIndex].status !== tasks[overIndex].status) { - const newTasks = [...tasks]; - newTasks[activeIndex].status = tasks[overIndex].status; - return arrayMove(newTasks, activeIndex, overIndex); + await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(newTask), } - - return arrayMove(tasks, activeIndex, overIndex); - }); + ); + } catch (err) { + console.error(err); } + }; - // Dropping a Task over an empty column - const isOverColumn = over.data.current?.type === "Column"; - if (isActiveTask && isOverColumn) { - setTasks((tasks) => { - const activeIndex = tasks.findIndex((t) => t.id === activeId); - const newTasks = [...tasks]; - if (activeIndex === -1 || !isTaskStatus(overId)) return tasks; - newTasks[activeIndex].status = overId; - return arrayMove(newTasks, activeIndex, activeIndex); - }); + const handleDeleteTask = async (taskId: string) => { + try { + const session = await supabase?.auth.getSession(); + const token = session?.data.session?.access_token; + + await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks/${taskId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + } catch (err) { + console.error(err); } }; + const handleEditTask = async (task: Task) => { + const title = window.prompt("Edit title", task.title); + if (!title) return; - function reconcileTaskPositions( - tasks: Task[], - status: TaskStatus - ) { - return tasks - .filter((task) => task.status === status) - .sort((a, b) => a.position - b.position) - .map((task, index) => ({ - ...task, - position: index, - })); - } - - function shouldSyncTaskUpdate( - originalTask: Task, - updatedTask: Task - ) { - return ( - originalTask.status !== - updatedTask.status || - originalTask.position !== - updatedTask.position - ); - } + const description = window.prompt("Edit description", task.description); - function createTaskSnapshot(tasks: Task[]) { - const snapshot: Task[] = []; + try { + const session = await supabase?.auth.getSession(); + const token = session?.data.session?.access_token; - for (const task of tasks) { - snapshot.push({ - ...task, - }); + await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks/${task.id}/edit`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ title, description }), + } + ); + } catch (err) { + console.error(err); } + }; - return snapshot; - } + /* ---------------- SOCKET ---------------- */ - function rollbackTaskState() { - setTasks(previousTasksRef.current); - } + useEffect(() => { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + const socket = io(apiUrl); - function isDuplicateRealtimeUpdate(task: Task) { - const now = Date.now(); + socketRef.current = socket; - const lastUpdate = - lastSocketUpdateRef.current.get(task.id) ?? 0; + socket.emit("join", { + username: "kanban-user", + room: "project-alpha", + }); - if (now - lastUpdate < 500) { - return true; - } + const timer = setTimeout(fetchTasks, 0); - lastSocketUpdateRef.current.set(task.id, now); + return () => { + clearTimeout(timer); + socket.disconnect(); + }; + }, []); - return false; - } + /* ---------------- DRAG ---------------- */ + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); - function normalizeColumnPositions( - taskList: Task[] - ) { - const normalizedTasks: Task[] = []; + const handleDragStart = (event: DragStartEvent) => { + dragOperationRef.current = String(event.active.id); + isDraggingRef.current = true; - for (const column of COLUMNS) { - const columnTasks = taskList - .filter( - (task) => task.status === column.id - ) - .sort( - (a, b) => a.position - b.position - ); - - columnTasks.forEach( - (task, index) => { - normalizedTasks.push({ - ...task, - position: index, - }); - } - ); - } + previousTasksRef.current = snapshotTasks(tasks); - return normalizedTasks; - } + const task = tasks.find((t) => t.id === event.active.id); + if (task) setActiveTask(task); + }; const handleDragEnd = async (event: DragEndEvent) => { - if (isSyncingRef.current) return; - isSyncingRef.current = true; setActiveTask(null); const { active, over } = event; - if (!over) { - dragOperationRef.current = null; - isDraggingRef.current = false; - isSyncingRef.current = false; - return; - } - - const activeId = active.id; - const overId = over.id; - - const activeTask = tasks.find((t) => t.id === activeId); - if (!activeTask) { - dragOperationRef.current = null; - isDraggingRef.current = false; - isSyncingRef.current = false; - return; - } - - let newStatus = activeTask.status; - - if (over.data.current?.type === "Column") { - if (!isTaskStatus(over.id)) { - dragOperationRef.current = null; + if (!over) { isDraggingRef.current = false; isSyncingRef.current = false; return; } - newStatus = over.id; - } else if (over.data.current?.type === "Task") { - const overTask = tasks.find((t) => t.id === overId); - if (overTask) { - newStatus = overTask.status; - } - } - - // Create updated task list - let updatedTasks = [...tasks]; - - // Update dragged task status - updatedTasks = updatedTasks.map((task) => - task.id === activeId - ? { ...task, status: newStatus } - : task - ); - - // Get tasks of affected column - const reconciledTasks = - reconcileTaskPositions( - updatedTasks, - newStatus - ); - - const reorderedTasks = - reconciledTasks.filter( - (task) => { - const originalTask = tasks.find( - (t) => t.id === task.id - ); - - return Boolean( - originalTask && - shouldSyncTaskUpdate( - originalTask, - task - ) - ); - }); - - if (reorderedTasks.length === 0) { - dragOperationRef.current = null; - isSyncingRef.current = false; - return; - } - - // Merge back updated positions - updatedTasks = updatedTasks.map((task) => { - const updated = reorderedTasks.find((t) => t.id === task.id); - return updated || task; - }); - - // Update frontend state - setTasks((prev) => - prev.map((task) => { - const updated = updatedTasks.find( - (t) => t.id === task.id - ); - - return updated || task; - }) - ); - try { - const session = await supabase?.auth.getSession(); - - const token = session?.data.session?.access_token; - // Send updates for all affected tasks - await Promise.all( - reorderedTasks.map((task) => - fetch( - `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks/${task.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - status: task.status, - position: task.position, - }), - } - ) - ) - ); + const task = tasks.find((t) => t.id === active.id); + if (!task) return; - // Emit socket updates - if (socketRef.current) { - reorderedTasks.forEach((task) => { - socketRef.current?.emit("task-moved", { - room: "project-alpha", - task, - }); - }); + let newStatus: TaskStatus = task.status; + if (isTaskStatus(over.id)) { + newStatus = over.id; } - }catch (error) { - rollbackTaskState(); - console.error( - "Failed to update task positions", - error + const updated = tasks.map((t) => + t.id === task.id ? { ...t, status: newStatus } : t ); - isDraggingRef.current = false; - isSyncingRef.current = false; - } - dragOperationRef.current = null; - isDraggingRef.current = false; - isSyncingRef.current = false; - setActiveTask(null); -}; - - - - const handleCreateTask = async (columnId: string) => { - const title = window.prompt("Task Title:"); - if (!title?.trim()) return; - - const newTask = { - title, - description: "", - status: columnId, - project_id: "default", - position: tasks.filter((t) => t.status === columnId).length, - }; + setTasks(normalizeColumnPositions(updated)); try { const session = await supabase?.auth.getSession(); - const token = session?.data.session?.access_token; - await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(newTask), - }); - // Task creation will be broadcasted by backend and handled by our socket listener - } catch (error) { - console.error("Failed to create task", error); - } - }; - - const handleDeleteTask = async (taskId: string) => { - if (!confirm("Are you sure you want to delete this task?")) return; - - try { - const session = await supabase?.auth.getSession(); - const token = session?.data.session?.access_token; - await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks/${taskId}`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - }, - }); - } catch (error) { - console.error("Failed to delete task", error); + await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks/${task.id}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ status: newStatus }), + } + ); + } catch (err) { + rollback(); } + + dragOperationRef.current = null; + isDraggingRef.current = false; + isSyncingRef.current = false; }; + /* ---------------- UI ---------------- */ + return ( -
- Render v - {renderMetrics.renderVersion} - | - Optimized - {renderMetrics.optimizedUpdates} - | - Skipped - {renderMetrics.skippedUpdates} -
+
{COLUMNS.map((col) => ( - task.status === col.id - ) - )} + tasks={tasks.filter((t) => t.status === col.id)} onCreateTask={() => handleCreateTask(col.id)} onDeleteTask={handleDeleteTask} onEditTask={handleEditTask} @@ -728,7 +312,13 @@ export function KanbanBoard() { ))}
- + {activeTask ? : null}
diff --git a/frontend/app/components/kanban/KanbanColumn.tsx b/frontend/app/components/kanban/KanbanColumn.tsx index 28a9f5a..78fc322 100644 --- a/frontend/app/components/kanban/KanbanColumn.tsx +++ b/frontend/app/components/kanban/KanbanColumn.tsx @@ -4,23 +4,30 @@ import React from "react"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { useDroppable } from "@dnd-kit/core"; import { KanbanCard } from "./KanbanCard"; -import { Task } from "./KanbanBoard"; +import type { Task, Column } from "./KanbanBoard"; import { Plus } from "lucide-react"; -interface Props { - column: { - id: string; - title: string; - }; +/* ---------------- TYPES ---------------- */ + +type Props = { + column: Column; tasks: Task[]; onCreateTask: () => void; onDeleteTask: (id: string) => void; onEditTask: (task: Task) => void; -} +}; + +/* ---------------- COMPONENT ---------------- */ -export function KanbanColumn({ column, tasks, onCreateTask, onDeleteTask, onEditTask }: Props) { +export function KanbanColumn({ + column, + tasks, + onCreateTask, + onDeleteTask, + onEditTask, +}: Props) { const { setNodeRef, isOver } = useDroppable({ - id: column.id, + id: column.id, // ✅ now correctly typed as TaskStatus data: { type: "Column", column, @@ -29,13 +36,17 @@ export function KanbanColumn({ column, tasks, onCreateTask, onDeleteTask, onEdit return (
+ + {/* HEADER */}

{column.title}

+ {tasks.length}
+ {/* DROP AREA */}
-
- t.id)} strategy={verticalListSortingStrategy}> + t.id)} + strategy={verticalListSortingStrategy} + > +
{tasks.length === 0 ? (
No tasks yet @@ -59,10 +73,11 @@ export function KanbanColumn({ column, tasks, onCreateTask, onDeleteTask, onEdit /> )) )} - -
+
+
+ {/* FOOTER */}
); -} +} \ No newline at end of file