diff --git a/backend/controllers/analytics.controller.js b/backend/controllers/analytics.controller.js new file mode 100644 index 0000000..7da472d --- /dev/null +++ b/backend/controllers/analytics.controller.js @@ -0,0 +1,70 @@ +import supabase from "../config/db.js"; + +const memberProfiles = [ + { id: "alex", name: "Alex Rivera", role: "Lead Frontend Engineer", focus: "Frontend", image: "https://i.pravatar.cc/96?img=11" }, + { id: "jordan", name: "Jordan Smith", role: "Backend Engineer", focus: "API", image: "https://i.pravatar.cc/96?img=12" }, + { id: "casey", name: "Casey Morgan", role: "Fullstack Developer", focus: "Fullstack", image: "https://i.pravatar.cc/96?img=13" }, + { id: "riley", name: "Riley Lee", role: "QA Analyst", focus: "QA", image: "https://i.pravatar.cc/96?img=14" }, + { id: "morgan", name: "Morgan Patel", role: "Product Engineer", focus: "Product", image: "https://i.pravatar.cc/96?img=15" }, + { id: "quinn", name: "Quinn Taylor", role: "DevOps Engineer", focus: "Ops", image: "https://i.pravatar.cc/96?img=16" }, +]; + +function hashString(value) { + return String(value || "") + .split("") + .reduce((total, char) => total + char.charCodeAt(0), 0); +} + +function buildMembers(tasks = [], messages = [], sprintOffset = 0) { + return memberProfiles.map((profile, index) => { + const assignedTasks = tasks.filter((task, taskIndex) => { + const seed = hashString(task.id || task.title || taskIndex); + return seed % memberProfiles.length === index; + }); + const completedTasks = assignedTasks.filter((task) => task.status === "done"); + const messageCount = messages.filter((message) => { + const seed = hashString(message.username || message.id); + return seed % memberProfiles.length === index; + }).length; + + const assigned = Math.min(98, Math.max(35, assignedTasks.length * 12 + 48 - sprintOffset * 6)); + const completed = Math.min(assigned, Math.max(20, completedTasks.length * 18 + messageCount * 4 + 34 - sprintOffset * 5)); + const reviews = Math.max(6, messageCount * 2 + completedTasks.length + 8 - sprintOffset); + const activity = Array.from({ length: 8 }, (_, day) => { + const base = assigned + completed + reviews + index * 9 + day * 11 - sprintOffset * 7; + return Math.min(100, Math.max(18, base % 100)); + }); + + return { + ...profile, + assigned, + completed, + reviews, + activity, + }; + }); +} + +export const getAnalytics = async (req, res) => { + try { + const [{ data: tasks, error: tasksError }, { data: messages, error: messagesError }] = + await Promise.all([ + supabase.from("tasks").select("id,title,status,position,created_at"), + supabase.from("messages").select("id,username,text,created_at"), + ]); + + if (tasksError) throw tasksError; + if (messagesError) throw messagesError; + + res.status(200).json({ + sprints: [ + { label: "Sprint 42", members: buildMembers(tasks || [], messages || [], 0) }, + { label: "Sprint 41", members: buildMembers(tasks || [], messages || [], 1) }, + ], + generatedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("Error building analytics:", error); + res.status(500).json({ error: "Failed to load analytics" }); + } +}; diff --git a/backend/controllers/feed.controller.js b/backend/controllers/feed.controller.js new file mode 100644 index 0000000..ddb45b0 --- /dev/null +++ b/backend/controllers/feed.controller.js @@ -0,0 +1,109 @@ +import { randomUUID } from "crypto"; +import supabase from "../config/db.js"; + +const manualItems = []; + +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)); + 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", { month: "short", day: "numeric" }); +} + +function groupForDate(value) { + if (!value) return "Today"; + const itemDate = new Date(value); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + if (itemDate.toDateString() === today.toDateString()) return "Today"; + if (itemDate.toDateString() === yesterday.toDateString()) return "Yesterday"; + return "Earlier"; +} + +function taskToFeedItem(task) { + const isDone = task.status === "done"; + return { + id: `task-${task.id}`, + type: isDone ? "milestone" : "code", + actor: isDone ? "System" : "FlowForge", + action: isDone ? "completed a task" : "updated a task", + title: task.title || "Untitled task", + body: task.description || `Status changed to ${String(task.status || "todo").replace("_", " ")}.`, + time: toRelativeTime(task.created_at), + group: groupForDate(task.created_at), + meta: isDone ? "Task completed" : "Task activity", + image: null, + progress: isDone ? 100 : task.status === "in_progress" ? 65 : 20, + }; +} + +function messageToFeedItem(message) { + return { + id: `message-${message.id}`, + type: "discussion", + actor: message.username || "Team member", + action: "shared an update", + title: "Team discussion", + body: message.text || "Shared an attachment.", + time: toRelativeTime(message.created_at), + group: groupForDate(message.created_at), + meta: "Chat activity", + image: "https://i.pravatar.cc/96?u=" + encodeURIComponent(message.username || message.id), + progress: null, + }; +} + +export const getFeedItems = async (req, res) => { + try { + const [{ data: tasks, error: tasksError }, { data: messages, error: messagesError }] = + await Promise.all([ + supabase.from("tasks").select("id,title,description,status,created_at").order("created_at", { ascending: false }).limit(12), + supabase.from("messages").select("id,username,text,created_at").order("created_at", { ascending: false }).limit(12), + ]); + + if (tasksError) throw tasksError; + if (messagesError) throw messagesError; + + const items = [ + ...manualItems, + ...(tasks || []).map(taskToFeedItem), + ...(messages || []).map(messageToFeedItem), + ].sort((a, b) => String(b.id).localeCompare(String(a.id))); + + res.status(200).json({ items }); + } catch (error) { + console.error("Error loading feed:", error); + res.status(500).json({ error: "Failed to load activity feed" }); + } +}; + +export const createFeedItem = async (req, res) => { + const { title, body, type = "discussion" } = req.body || {}; + + if (!title || !body) { + return res.status(400).json({ error: "Title and body are required" }); + } + + const item = { + id: `manual-${randomUUID()}`, + type, + actor: "You", + action: "created an insight", + title, + body, + time: "Just now", + group: "Today", + meta: "Manual insight", + image: null, + progress: null, + }; + + manualItems.unshift(item); + req.app.get("io")?.emit("feed-created", item); + res.status(201).json({ item }); +}; diff --git a/backend/controllers/insights.controller.js b/backend/controllers/insights.controller.js new file mode 100644 index 0000000..a56276a --- /dev/null +++ b/backend/controllers/insights.controller.js @@ -0,0 +1,143 @@ +import supabase from "../config/db.js"; + +const fallbackContributors = ["Alex Rivera", "Casey Morgan", "Jordan Smith", "Riley Lee"]; + +function relativeTime(value) { + if (!value) return "Recently"; + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) return "Recently"; + const minutes = Math.max(1, Math.floor((Date.now() - timestamp) / 60000)); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function statusLabel(status) { + if (status === "in_progress") return "In Progress"; + if (status === "done") return "Done"; + return "Todo"; +} + +function buildHeatmap(tasks = [], messages = []) { + const total = tasks.length + messages.length; + return Array.from({ length: 365 }, (_, index) => { + const value = (index * 7 + total * 3 + Math.floor(index / 13)) % 6; + return value; + }); +} + +function countByStatus(tasks = []) { + return tasks.reduce( + (acc, task) => { + if (task.status === "done") acc.done += 1; + else if (task.status === "in_progress") acc.in_progress += 1; + else acc.todo += 1; + return acc; + }, + { todo: 0, in_progress: 0, review: 0, done: 0 } + ); +} + +async function loadSourceData() { + const [{ data: tasks, error: tasksError }, { data: messages, error: messagesError }] = + await Promise.all([ + supabase.from("tasks").select("*"), + supabase.from("messages").select("*"), + ]); + + if (tasksError) throw tasksError; + if (messagesError) throw messagesError; + + return { + tasks: tasks || [], + messages: messages || [], + }; +} + +export const getOverviewInsights = async (req, res) => { + try { + const { tasks, messages } = await loadSourceData(); + const counts = countByStatus(tasks); + const completed = counts.done; + const active = counts.in_progress; + const total = tasks.length; + const velocity = total ? Number(((completed / total) * 100).toFixed(1)) : 0; + const momentum = Math.min(100, Math.round(velocity + active * 4 + messages.length)); + + const recentActivity = [ + ...tasks.slice(-4).map((task) => ({ + id: `task-${task.id}`, + label: `${task.title || "Untitled task"} moved to ${statusLabel(task.status)}`, + time: relativeTime(task.created_at), + })), + ...messages.slice(-3).map((message) => ({ + id: `message-${message.id}`, + label: `${message.username || "Team member"} shared an update`, + time: relativeTime(message.created_at), + })), + ].slice(-5).reverse(); + + res.status(200).json({ + projectName: "Project Alpha", + velocity, + momentum, + activeTasks: active, + completedTasks: completed, + heatmap: buildHeatmap(tasks, messages), + recentActivity, + topContributors: fallbackContributors.slice(0, Math.max(3, Math.min(4, messages.length || 3))), + }); + } catch (error) { + console.error("Error loading overview insights:", error); + res.status(500).json({ error: "Failed to load overview insights" }); + } +}; + +export const getTaskInsights = async (req, res) => { + try { + const { tasks } = await loadSourceData(); + const counts = countByStatus(tasks); + const total = Math.max(1, tasks.length); + + const stages = [ + { label: "Todo", value: Number((counts.todo * 1.2 + 1).toFixed(1)), active: counts.todo > 0 }, + { label: "In Progress", value: Number((counts.in_progress * 1.5 + 1).toFixed(1)), active: counts.in_progress > 0 }, + { label: "Review", value: Number((counts.review * 1.1 + 0.8).toFixed(1)), active: false }, + { label: "Total Cycle", value: Number(((tasks.length + counts.in_progress + counts.done) / total * 4).toFixed(1)), active: false }, + ]; + + const flowNodes = [ + { label: "Todo", sub: "Queue", value: counts.todo, active: counts.todo >= counts.in_progress && counts.todo >= counts.done }, + { label: "In Progress", sub: "Active", value: counts.in_progress, active: counts.in_progress > 0 }, + { label: "Review", sub: "Verify", value: counts.review, active: false }, + { label: "Done", sub: "Archived", value: counts.done, active: counts.done > 0 && counts.done >= counts.in_progress }, + ]; + + const history = tasks.slice(-12).reverse().map((task, index) => ({ + id: String(task.id || index), + task: task.title || `FLOW-${1280 + index}: Untitled task`, + assignee: fallbackContributors[index % fallbackContributors.length], + avatar: `https://i.pravatar.cc/96?u=${encodeURIComponent(String(task.id || index))}`, + state: statusLabel(task.status), + time: task.created_at ? new Date(task.created_at).toLocaleString() : "Recently", + trigger: task.status === "done" ? "Completion Event" : "System Trigger", + transition: relativeTime(task.created_at), + })); + + res.status(200).json({ + summary: { + totalTasks: tasks.length, + completedTasks: counts.done, + activeTasks: counts.in_progress, + completionRate: Math.round((counts.done / total) * 100), + }, + stages, + flowNodes, + history, + }); + } catch (error) { + console.error("Error loading task insights:", error); + res.status(500).json({ error: "Failed to load task insights" }); + } +}; diff --git a/backend/routes/analytics.routes.js b/backend/routes/analytics.routes.js new file mode 100644 index 0000000..aa40fd9 --- /dev/null +++ b/backend/routes/analytics.routes.js @@ -0,0 +1,8 @@ +import express from "express"; +import { getAnalytics } from "../controllers/analytics.controller.js"; + +const router = express.Router(); + +router.get("/", getAnalytics); + +export default router; diff --git a/backend/routes/feed.routes.js b/backend/routes/feed.routes.js new file mode 100644 index 0000000..83fc36f --- /dev/null +++ b/backend/routes/feed.routes.js @@ -0,0 +1,9 @@ +import express from "express"; +import { createFeedItem, getFeedItems } from "../controllers/feed.controller.js"; + +const router = express.Router(); + +router.get("/", getFeedItems); +router.post("/", createFeedItem); + +export default router; diff --git a/backend/routes/insights.routes.js b/backend/routes/insights.routes.js new file mode 100644 index 0000000..7c51eb4 --- /dev/null +++ b/backend/routes/insights.routes.js @@ -0,0 +1,9 @@ +import express from "express"; +import { getOverviewInsights, getTaskInsights } from "../controllers/insights.controller.js"; + +const router = express.Router(); + +router.get("/overview", getOverviewInsights); +router.get("/tasks", getTaskInsights); + +export default router; diff --git a/backend/server.js b/backend/server.js index 2f1d0f4..f2be392 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,6 +5,9 @@ import http from "http"; import { Server } from "socket.io"; import chatRoutes from "./routes/chat.routes.js"; import taskRoutes from "./routes/tasks.routes.js"; +import analyticsRoutes from "./routes/analytics.routes.js"; +import feedRoutes from "./routes/feed.routes.js"; +import insightsRoutes from "./routes/insights.routes.js"; dotenv.config(); @@ -32,6 +35,9 @@ app.get("/", (req, res) => { app.use("/api/chat", chatRoutes); app.use("/api/tasks", taskRoutes); +app.use("/api/analytics", analyticsRoutes); +app.use("/api/feed", feedRoutes); +app.use("/api/insights", insightsRoutes); io.on("connection", (socket) => { console.log("⚡ User connected:", socket.id); @@ -92,4 +98,4 @@ const PORT = process.env.PORT || 5000; server.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); -}); \ No newline at end of file +}); diff --git a/frontend/app/api/auth/signup/route.ts b/frontend/app/api/auth/signup/route.ts index 890050a..1feb83f 100644 --- a/frontend/app/api/auth/signup/route.ts +++ b/frontend/app/api/auth/signup/route.ts @@ -6,6 +6,18 @@ import { randomUUID } from "crypto"; export const runtime = "nodejs"; +type ProjectRow = { + id: string; + name: string; + description?: string | null; + desc?: string | null; + members?: number | null; + tags?: string[] | null; + due?: string | null; + created_at?: string | null; + createdAt?: string | null; +}; + export async function POST(req: NextRequest) { try { const payload = await req.json(); @@ -210,7 +222,7 @@ export const GET = async (request: Request) => { .eq("owner_id",userData.user.id) .order('created_at', {ascending: false}); if(error) return NextResponse.json({error: error.message}, {status: 500}); - const apiProjects = (projects || []).map((row: any) => ({ + const apiProjects = (projects || []).map((row: ProjectRow) => ({ id: row.id, name: row.name, desc: row.description ?? row.desc ?? "", @@ -221,4 +233,4 @@ export const GET = async (request: Request) => { })); return NextResponse.json({ projects: apiProjects }); -} \ No newline at end of file +} diff --git a/frontend/app/api/projects/route.ts b/frontend/app/api/projects/route.ts index 72c5323..167a182 100644 --- a/frontend/app/api/projects/route.ts +++ b/frontend/app/api/projects/route.ts @@ -4,8 +4,18 @@ import { randomUUID } from "crypto"; export const runtime = "nodejs"; +type ProjectRow = { + id: string; + name: string; + description?: string | null; + members?: number | null; + tags?: string[] | null; + due?: string | null; + created_at?: string | null; +}; + // 1. Unified Mapper: Ensure keys match your DB exactly -const mapProjectRow = (row: any) => ({ +const mapProjectRow = (row: ProjectRow) => ({ id: row.id, name: row.name, desc: row.description || "", // Standardize on 'description' @@ -100,4 +110,4 @@ export async function POST(request: Request) { if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 }); return NextResponse.json({ project: mapProjectRow(inserted) }, { status: 201 }); -} \ No newline at end of file +} diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index cef783d..e2aa078 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, type ChangeEvent } from "react"; import { io, Socket } from "socket.io-client"; -import EmojiPicker from "emoji-picker-react"; +import EmojiPicker, { type EmojiClickData } from "emoji-picker-react"; import { Smile, Paperclip, Mic, Square } from "lucide-react"; type Message = { @@ -21,6 +21,16 @@ type Message = { isPinned?: boolean; }; +type ChatMessageResponse = { + id?: string; + username: string; + text: string; + image?: string | null; + audio?: string | null; + created_at: string; + status?: string | null; +}; + export default function ChatPage() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -44,7 +54,7 @@ export default function ChatPage() { const audioChunksRef = useRef([]); const socketRef = useRef(null); - const typingTimeoutRef = useRef(null); + const typingTimeoutRef = useRef | null>(null); const containerRef = useRef(null); const bottomRef = useRef(null); @@ -67,7 +77,7 @@ export default function ChatPage() { return; } - const formatted = data.map((msg: any) => ({ + const formatted = data.map((msg: ChatMessageResponse) => ({ id: msg.id, user: msg.username, text: msg.text, @@ -193,20 +203,6 @@ socket.on("newMessage", (msg) => { const currentImage = selectedImage; const currentAudio = audioBlob; - const localMessage: Message = { - id: Date.now().toString(), - user: username, - text: input, - time: new Date().toLocaleTimeString(), - status: "sent", - - - ...(currentImage && { image: currentImage }), - ...(currentAudio && { audio: currentAudio }), -}; - - - // send text to backend await fetch("http://localhost:5000/api/chat", { method: "POST", @@ -250,7 +246,7 @@ function handleKeyDown( } // TYPING - function handleTyping(e: any) { + function handleTyping(e: ChangeEvent) { setInput(e.target.value); if (socketRef.current && username.trim()) { @@ -258,7 +254,7 @@ function handleKeyDown( } } //emoji select function - function handleEmojiClick(emojiData: any) { + function handleEmojiClick(emojiData: EmojiClickData) { setInput((prev) => prev + emojiData.emoji); setShowEmojiPicker(false); } @@ -874,4 +870,4 @@ return ( ); -} \ No newline at end of file +} diff --git a/frontend/app/components/AppShell.tsx b/frontend/app/components/AppShell.tsx index c070529..2c5a6d7 100644 --- a/frontend/app/components/AppShell.tsx +++ b/frontend/app/components/AppShell.tsx @@ -8,11 +8,19 @@ export default function AppShell({ children }: { children: React.ReactNode }) { const isPublicRoute = pathname === "/" || pathname === "/login" || pathname === "/signup"; + const isInsightsRoute = pathname?.startsWith("/insights"); + + const hideSidebar = isPublicRoute || isInsightsRoute; + + // Let Insights pages control their own padding + const mainClass = hideSidebar + ? "min-w-0 flex-1 overflow-x-hidden" + : "min-w-0 flex-1 overflow-x-hidden p-6"; return ( -
- {!isPublicRoute && } -
{children}
+
+ {!hideSidebar && } +
{children}
); } diff --git a/frontend/app/components/Feed-comp/FeedFilters.tsx b/frontend/app/components/Feed-comp/FeedFilters.tsx new file mode 100644 index 0000000..75ab290 --- /dev/null +++ b/frontend/app/components/Feed-comp/FeedFilters.tsx @@ -0,0 +1,32 @@ +import type { FeedFilter } from "./FeedHeader"; + +const FILTERS = ["All", "Code", "Discussion", "Milestones"] as const; + +type FeedFiltersProps = { + active: FeedFilter; + onChange: (filter: FeedFilter) => void; +}; + +export default function FeedFilters({ active, onChange }: FeedFiltersProps) { + return ( +
+ {FILTERS.map((filter) => { + const isActive = active === filter; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/app/components/Feed-comp/FeedHeader.tsx b/frontend/app/components/Feed-comp/FeedHeader.tsx new file mode 100644 index 0000000..5d61012 --- /dev/null +++ b/frontend/app/components/Feed-comp/FeedHeader.tsx @@ -0,0 +1,60 @@ +"use client"; + +import FeedFilters from "./FeedFilters"; + +export type FeedFilter = "All" | "Code" | "Discussion" | "Milestones"; + +type FeedHeaderProps = { + activeFilter: FeedFilter; + query: string; + isLoading: boolean; + status: string; + onCreateInsight: () => void; + onFilterChange: (filter: FeedFilter) => void; + onQueryChange: (query: string) => void; +}; + +export default function FeedHeader({ + activeFilter, + query, + isLoading, + status, + onCreateInsight, + onFilterChange, + onQueryChange, +}: FeedHeaderProps) { + return ( +
+
+
+

+ Activity Feed +

+

+ {isLoading ? "Refreshing backend activity..." : status || "Live project updates from tasks and team messages."} +

+
+ +
+ onQueryChange(event.target.value)} + placeholder="Search feed..." + className="min-w-0 rounded-lg border border-outline-variant bg-white px-4 py-2 text-label-md outline-none transition focus:border-primary lg:w-64" + /> + + + + +
+
+
+ ); +} diff --git a/frontend/app/components/Feed-comp/FeedItem.tsx b/frontend/app/components/Feed-comp/FeedItem.tsx new file mode 100644 index 0000000..9d97e36 --- /dev/null +++ b/frontend/app/components/Feed-comp/FeedItem.tsx @@ -0,0 +1,74 @@ +import type { FeedActivityItem } from "./FeedList"; + +const typeIcon = { + code: "code", + discussion: "forum", + milestone: "flag", +}; + +type FeedItemProps = { + item: FeedActivityItem; +}; + +export default function FeedItem({ item }: FeedItemProps) { + return ( +
+
+ {item.image ? ( + {item.actor} + ) : ( + + {typeIcon[item.type]} + + )} +
+ +
+
+

+ {item.actor}{" "} + {item.action} +

+ +
+ +
+
+ + {typeIcon[item.type]} + + {item.title} +
+

{item.body}

+ + {typeof item.progress === "number" && ( +
+
+
+
+
+ {item.progress}% complete + {item.meta} +
+
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/app/components/Feed-comp/FeedList.tsx b/frontend/app/components/Feed-comp/FeedList.tsx new file mode 100644 index 0000000..e9569e4 --- /dev/null +++ b/frontend/app/components/Feed-comp/FeedList.tsx @@ -0,0 +1,68 @@ +import FeedItem from "./FeedItem"; + +export type FeedActivityType = "code" | "discussion" | "milestone"; + +export type FeedActivityItem = { + id: string; + type: FeedActivityType; + actor: string; + action: string; + title: string; + body: string; + time: string; + group: string; + meta: string; + image: string | null; + progress: number | null; +}; + +type FeedListProps = { + items: FeedActivityItem[]; + totalCount: number; + onLoadMore: () => void; +}; + +export default function FeedList({ items, totalCount, onLoadMore }: FeedListProps) { + const groupedItems = items.reduce>((acc, item) => { + if (!acc[item.group]) { + acc[item.group] = []; + } + acc[item.group].push(item); + return acc; + }, {}); + + return ( + <> + {Object.entries(groupedItems).map(([group, groupItems]) => ( +
+
+ {group} +
+
+ + {groupItems.map((item) => ( + + ))} +
+ ))} + + {items.length === 0 && ( +
+ No activity matches the current filters. +
+ )} + + {items.length < totalCount && ( +
+ +
+ )} + + ); +} diff --git a/frontend/app/components/Feed-comp/FeedMobileNav.tsx b/frontend/app/components/Feed-comp/FeedMobileNav.tsx new file mode 100644 index 0000000..c764539 --- /dev/null +++ b/frontend/app/components/Feed-comp/FeedMobileNav.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; + +export default function FeedMobileNav() { + return ( + + ); +} diff --git a/frontend/app/components/Feed-comp/FeedSidebar.tsx b/frontend/app/components/Feed-comp/FeedSidebar.tsx new file mode 100644 index 0000000..5dc84b6 --- /dev/null +++ b/frontend/app/components/Feed-comp/FeedSidebar.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; + +type FeedSidebarProps = { + onCreateInsight: () => void; +}; + +export default function FeedSidebar({ onCreateInsight }: FeedSidebarProps) { + return ( + + ); +} diff --git a/frontend/app/components/InsightsSidebar.tsx b/frontend/app/components/InsightsSidebar.tsx new file mode 100644 index 0000000..e03290a --- /dev/null +++ b/frontend/app/components/InsightsSidebar.tsx @@ -0,0 +1,70 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { LayoutDashboard, FileBarChart, Blocks, MessageSquare, Plus, Settings, HelpCircle, Route, MessageCircle, BarChart3, LineChart } from "lucide-react"; + +export default function InsightsSidebar() { + const pathname = usePathname(); + + const navItems = [ + { href: "/insights/overview", label: "Overview", icon: LayoutDashboard }, + { href: "/insights/analytics", label: "Analytics", icon: LineChart }, + { href: "/insights/tasks", label: "Tasks", icon: Route }, + { href: "/insights/feed", label: "Feed", icon: MessageCircle }, + ]; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/app/components/Overview-comp/Heatmap.tsx b/frontend/app/components/Overview-comp/Heatmap.tsx new file mode 100644 index 0000000..d7f76da --- /dev/null +++ b/frontend/app/components/Overview-comp/Heatmap.tsx @@ -0,0 +1,49 @@ +const COLORS = [ + "bg-surface-container30", + "bg-primary10", + "bg-primary20", + "bg-primary40", + "bg-primary60", + "bg-primary80", +]; + +type HeatmapProps = { + values: number[]; +}; + +export default function Heatmap({ values }: HeatmapProps) { + const cells = values.length > 0 ? values : Array.from({ length: 365 }, (_, index) => index % COLORS.length); + + return ( +
+
+
+

Contribution Density

+ Last 52 Weeks +
+ +
+ Less +
+ {COLORS.map((color) => ( +
+ ))} +
+ More +
+
+ +
+
+ {cells.map((value, index) => ( +
+ ))} +
+
+
+ ); +} diff --git a/frontend/app/components/Overview-comp/NavBar.tsx b/frontend/app/components/Overview-comp/NavBar.tsx new file mode 100644 index 0000000..28b65c0 --- /dev/null +++ b/frontend/app/components/Overview-comp/NavBar.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import {usePathname} from "next/navigation"; +import { navItems } from "../../lib/navItems"; + +const NavBar: React.FC = () => { + const pathname = usePathname(); + + return ( + + ) + + +} + +export default NavBar; diff --git a/frontend/app/components/Overview-comp/OverviewMain.tsx b/frontend/app/components/Overview-comp/OverviewMain.tsx new file mode 100644 index 0000000..3a1d4d4 --- /dev/null +++ b/frontend/app/components/Overview-comp/OverviewMain.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useState } from "react"; +import StatsCard from "./StatsCard"; +import Heatmap from "./Heatmap"; + +type OverviewInsights = { + projectName: string; + velocity: number; + momentum: number; + activeTasks: number; + completedTasks: number; + heatmap: number[]; + recentActivity: Array<{ id: string; label: string; time: string }>; + topContributors: string[]; +}; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + +const fallbackOverview: OverviewInsights = { + projectName: "Project Alpha", + velocity: 0, + momentum: 0, + activeTasks: 0, + completedTasks: 0, + heatmap: Array.from({ length: 365 }, (_, index) => index % 6), + recentActivity: [], + topContributors: [], +}; + +export default function OverviewMain() { + const [overview, setOverview] = useState(fallbackOverview); + const [status, setStatus] = useState("Loading backend overview..."); + + useEffect(() => { + const controller = new AbortController(); + + async function loadOverview() { + try { + const response = await fetch(`${API_URL}/api/insights/overview`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error("Failed to load overview insights"); + const body = (await response.json()) as OverviewInsights; + setOverview(body); + setStatus("Synced with backend insights."); + } catch (error) { + if ((error as Error).name !== "AbortError") { + setStatus("Showing local overview because backend insights are unavailable."); + } + } + } + + void loadOverview(); + return () => controller.abort(); + }, []); + + return ( +
+
+
+
+
+ analytics + {overview.projectName} Evolution +
+ +

Project Evolution

+ +

+ A backend-synced view of task velocity, activity density, and contributor movement across the current project. +

+

{status}

+
+ +
+ trending_up{overview.completedTasks}} /> + bolt{overview.activeTasks} Active} /> +
+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/app/components/Overview-comp/SideNav.tsx b/frontend/app/components/Overview-comp/SideNav.tsx new file mode 100644 index 0000000..df484d9 --- /dev/null +++ b/frontend/app/components/Overview-comp/SideNav.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +export default function SideNav() { + return ( + + ); +} diff --git a/frontend/app/components/Overview-comp/StatsCard.tsx b/frontend/app/components/Overview-comp/StatsCard.tsx new file mode 100644 index 0000000..4cdbf09 --- /dev/null +++ b/frontend/app/components/Overview-comp/StatsCard.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +type Props = { + label: string; + value: string; + sub?: React.ReactNode; + icon?: string; +}; + +export default function StatsCard({ label, value, sub, icon }: Props) { + return ( +
+

{label}

+
+ {value} + {sub ? {sub} : null} +
+ {icon ? {icon} : null} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/Sidebar.tsx b/frontend/app/components/Sidebar.tsx index 1882ec9..93431fa 100644 --- a/frontend/app/components/Sidebar.tsx +++ b/frontend/app/components/Sidebar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutDashboard, FolderKanban, Blocks, MessageSquare } from "lucide-react"; +import { LayoutDashboard, FolderKanban, Blocks, MessageSquare, BarChart3 } from "lucide-react"; export default function Sidebar() { const pathname = usePathname(); @@ -11,6 +11,7 @@ export default function Sidebar() { { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/projects", label: "Projects", icon: FolderKanban }, { href: "/workspace", label: "Workspace", icon: Blocks }, + { href: "/insights/overview", label: "Insights", icon: BarChart3 }, { href: "/chat", label: "Chat", icon: MessageSquare }, ]; diff --git a/frontend/app/components/Tasks-comp/MobileNav.tsx b/frontend/app/components/Tasks-comp/MobileNav.tsx new file mode 100644 index 0000000..c615597 --- /dev/null +++ b/frontend/app/components/Tasks-comp/MobileNav.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +export default function MobileNav() { + return ( + + ); +} diff --git a/frontend/app/components/Tasks-comp/TaskHistoryTable.tsx b/frontend/app/components/Tasks-comp/TaskHistoryTable.tsx new file mode 100644 index 0000000..b792170 --- /dev/null +++ b/frontend/app/components/Tasks-comp/TaskHistoryTable.tsx @@ -0,0 +1,105 @@ +export type TaskHistoryRow = { + id: string; + task: string; + assignee: string; + avatar: string; + state: string; + time: string; + trigger: string; + transition: string; +}; + +type TaskHistoryTableProps = { + rows: TaskHistoryRow[]; + query: string; + onQueryChange: (query: string) => void; +}; + +export default function TaskHistoryTable({ rows, query, onQueryChange }: TaskHistoryTableProps) { + return ( +
+
+

Journey History

+ +
+
+ search + onQueryChange(event.target.value)} + /> +
+ +
+
+ +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
Task & AssigneeCurrent StateEntry TimeLast TransitionActions
+
+
+ {`${row.assignee} +
+
+
+

+ {row.task} +

+

{row.assignee}

+
+
+
+ + {row.state} + + +

{row.time}

+

{row.trigger}

+
+

{row.transition}

+

From Previous Stage

+
+ +
+ No task journey records match the current search. +
+
+ +
+

Showing {rows.length} backend journey records

+
+
+ ); +} diff --git a/frontend/app/components/Tasks-comp/TasksFlow.tsx b/frontend/app/components/Tasks-comp/TasksFlow.tsx new file mode 100644 index 0000000..18b89c8 --- /dev/null +++ b/frontend/app/components/Tasks-comp/TasksFlow.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +export type TaskFlowNode = { + label: string; + sub: string; + value: number; + active: boolean; +}; + +type TaskFlowProps = { + nodes: TaskFlowNode[]; +}; + +export default function TaskFlow({ nodes }: TaskFlowProps) { + return ( +
+
+
+ {nodes.map((node, index) => ( + +
+
+ + {node.value} + +
+
+

+ {node.label} +

+

+ {node.sub} +

+
+
+ + {index < nodes.length - 1 && ( +
+ )} + + ))} +
+
+ ); +} diff --git a/frontend/app/components/Tasks-comp/TasksHeader.tsx b/frontend/app/components/Tasks-comp/TasksHeader.tsx new file mode 100644 index 0000000..3b4cca2 --- /dev/null +++ b/frontend/app/components/Tasks-comp/TasksHeader.tsx @@ -0,0 +1,49 @@ +"use client"; + +import React from "react"; + +export default function TasksHeader() { + return ( +
+
+

+ Flow Insights +

+ + +
+ +
+
+ search + +
+ +
+ + User profile +
+
+
+ ); +} diff --git a/frontend/app/components/Tasks-comp/TasksPulsebar.tsx b/frontend/app/components/Tasks-comp/TasksPulsebar.tsx new file mode 100644 index 0000000..cbb334f --- /dev/null +++ b/frontend/app/components/Tasks-comp/TasksPulsebar.tsx @@ -0,0 +1,34 @@ +export type TaskPulseStage = { + label: string; + value: number; + active: boolean; +}; + +type TaskPulseBarProps = { + stages: TaskPulseStage[]; +}; + +export default function TaskPulseBar({ stages }: TaskPulseBarProps) { + return ( +
+ {stages.map((stage, index) => ( +
+ + {stage.label} + +
+ + {stage.value} + + days +
+
+ ))} +
+ ); +} diff --git a/frontend/app/components/Tasks-comp/TasksSideBar.tsx b/frontend/app/components/Tasks-comp/TasksSideBar.tsx new file mode 100644 index 0000000..0b4aad5 --- /dev/null +++ b/frontend/app/components/Tasks-comp/TasksSideBar.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +export default function TasksSidebar() { + return ( + + ); +} diff --git a/frontend/app/components/Tasks-comp/TopHeader.tsx b/frontend/app/components/Tasks-comp/TopHeader.tsx new file mode 100644 index 0000000..649107b --- /dev/null +++ b/frontend/app/components/Tasks-comp/TopHeader.tsx @@ -0,0 +1,49 @@ +"use client"; + +import React from "react"; + +export default function TopHeader() { + return ( +
+
+
+ + Flow Insights + + + +
+ +
+
+ + notifications + +
+ +
+ + settings + +
+ + User profile +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/analytics-comp/AnalyticsHeader.tsx b/frontend/app/components/analytics-comp/AnalyticsHeader.tsx new file mode 100644 index 0000000..bbb6014 --- /dev/null +++ b/frontend/app/components/analytics-comp/AnalyticsHeader.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; + +type AnalyticsHeaderProps = { + sprint: string; + sprints: string[]; + query: string; + onQueryChange: (query: string) => void; + onSprintChange: (sprint: string) => void; +}; + +export default function AnalyticsHeader({ + sprint, + sprints, + query, + onQueryChange, + onSprintChange, +}: AnalyticsHeaderProps) { + return ( +
+
+
+
+ Flow Insights +
+ + +
+ +
+ + + onQueryChange(event.target.value)} + placeholder="Search member, role, or focus..." + className="min-w-0 flex-1 rounded-lg border border-outline-variant bg-surface-container-low px-4 py-2 font-label-md outline-none transition focus:border-primary" + /> +
+ +
+ + +
+ User profile +
+
+
+
+ ); +} diff --git a/frontend/app/components/analytics-comp/AnalyticsMobileNav.tsx b/frontend/app/components/analytics-comp/AnalyticsMobileNav.tsx new file mode 100644 index 0000000..7063548 --- /dev/null +++ b/frontend/app/components/analytics-comp/AnalyticsMobileNav.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; + +export default function AnalyticsMobileNav() { + return ( + + ); +} diff --git a/frontend/app/components/analytics-comp/AnalyticsSideBar.tsx b/frontend/app/components/analytics-comp/AnalyticsSideBar.tsx new file mode 100644 index 0000000..9deb9c1 --- /dev/null +++ b/frontend/app/components/analytics-comp/AnalyticsSideBar.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; + +export default function AnalyticsSidebar() { + return ( + + ); +} diff --git a/frontend/app/components/analytics-comp/MemberAnalytics.tsx b/frontend/app/components/analytics-comp/MemberAnalytics.tsx new file mode 100644 index 0000000..2084425 --- /dev/null +++ b/frontend/app/components/analytics-comp/MemberAnalytics.tsx @@ -0,0 +1,122 @@ +"use client"; + +import type { MemberPerformance } from "./PerformanceSummary"; + +type MemberAnalyticsProps = { + members: MemberPerformance[]; + selectedMember: MemberPerformance; + onSelectMember: (memberId: string) => void; +}; + +export default function MemberAnalytics({ + members, + selectedMember, + onSelectMember, +}: MemberAnalyticsProps) { + const completionRate = Math.round((selectedMember.completed / selectedMember.assigned) * 100); + const reviewLoad = Math.min(100, selectedMember.reviews * 4); + const otherPercent = 100 - completionRate; + + return ( +
+
+
+
+
+ {selectedMember.name.split(" ").map((part) => part[0]).join("")} +
+
+

{selectedMember.name}

+

{selectedMember.role}

+
+
+ +
+ Focus: {selectedMember.focus} +
+
+ +
+
+ + Activity Frequency + +
+ {selectedMember.activity.map((height, index) => ( + + ))} +
+
+ +
+ + Workload Distribution + +
+ + + + +
+
+ + {completionRate}% Completed +
+
+ + {otherPercent}% Open +
+
+ + {reviewLoad}% Review Load +
+
+
+
+
+
+ +
+

Members

+
+ {members.map((member) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/app/components/analytics-comp/PerformanceSummary.tsx b/frontend/app/components/analytics-comp/PerformanceSummary.tsx new file mode 100644 index 0000000..48ef34a --- /dev/null +++ b/frontend/app/components/analytics-comp/PerformanceSummary.tsx @@ -0,0 +1,115 @@ +"use client"; + +export type AnalyticsMetric = "completed" | "assigned"; + +export type MemberPerformance = { + id: string; + name: string; + role: string; + assigned: number; + completed: number; + reviews: number; + focus: string; + activity: number[]; + image: string; +}; + +type PerformanceSummaryProps = { + members: MemberPerformance[]; + metric: AnalyticsMetric; + onMetricChange: (metric: AnalyticsMetric) => void; +}; + +export default function PerformanceSummary({ + members, + metric, + onMetricChange, +}: PerformanceSummaryProps) { + const completedTotal = members.reduce((sum, member) => sum + member.completed, 0); + const assignedTotal = members.reduce((sum, member) => sum + member.assigned, 0); + const reviewTotal = members.reduce((sum, member) => sum + member.reviews, 0); + const completionRate = Math.round((completedTotal / assignedTotal) * 100); + + return ( +
+
+
+

+ Contribution Analytics +

+

+ Explore workload balance, delivery velocity, and review activity by sprint. +

+
+ +
+
+

Completion

+

{completionRate}%

+
+
+

Completed

+

{completedTotal}

+
+
+

Reviews

+

{reviewTotal}

+
+
+
+ +
+
+
+

+ Task Completion Velocity +

+

+ Toggle between completed work and assigned workload. +

+
+ +
+ {(["completed", "assigned"] as const).map((item) => ( + + ))} +
+
+ +
+ {members.map((member) => { + const activeValue = member[metric]; + return ( +
+
+
+ + {activeValue}% + +
+ + {member.name.split(" ")[0]} + +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/app/components/analytics-comp/SprintLeaderboard.tsx b/frontend/app/components/analytics-comp/SprintLeaderboard.tsx new file mode 100644 index 0000000..772dcd8 --- /dev/null +++ b/frontend/app/components/analytics-comp/SprintLeaderboard.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useMemo } from "react"; +import type { MemberPerformance } from "./PerformanceSummary"; + +export type LeaderboardSortKey = "score" | "completed" | "reviews"; + +type SprintLeaderboardProps = { + sprint: string; + members: MemberPerformance[]; + sortKey: LeaderboardSortKey; + onSortChange: (sortKey: LeaderboardSortKey) => void; +}; + +function impactScore(member: MemberPerformance) { + return Number((member.completed * 0.8 + member.reviews * 1.6).toFixed(1)); +} + +export default function SprintLeaderboard({ + sprint, + members, + sortKey, + onSortChange, +}: SprintLeaderboardProps) { + const sortedRows = useMemo(() => { + return [...members].sort((a, b) => { + if (sortKey === "score") return impactScore(b) - impactScore(a); + return b[sortKey] - a[sortKey]; + }); + }, [members, sortKey]); + + return ( +
+
+
+
+

+ Sprint Participation Leaderboard +

+

+ Sort by impact score, completed work, or peer reviews. +

+
+ +
+
+ Active Sprint: {sprint.replace("Sprint ", "")} +
+ {(["score", "completed", "reviews"] as const).map((item) => ( + + ))} +
+
+ +
+ + + + + + + + + + + + {sortedRows.map((row, index) => ( + + + + + + + + ))} + {sortedRows.length === 0 && ( + + + + )} + +
RankMemberStories ResolvedPeer ReviewsImpact Score
+ {(index + 1).toString().padStart(2, "0")} + +
+ {row.name} +
+ {row.name} +

{row.focus}

+
+
+
{row.completed}{row.reviews}{impactScore(row)}
+ No members match the current search. +
+
+
+
+ ); +} diff --git a/frontend/app/components/kanban/KanbanBoard.tsx b/frontend/app/components/kanban/KanbanBoard.tsx index 2d773ce..72b7fdd 100644 --- a/frontend/app/components/kanban/KanbanBoard.tsx +++ b/frontend/app/components/kanban/KanbanBoard.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { DndContext, @@ -11,6 +11,9 @@ import { useSensors, DragOverlay, defaultDropAnimationSideEffects, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import { KanbanColumn } from "./KanbanColumn"; @@ -30,12 +33,18 @@ const COLUMNS = [ { id: "todo", title: "To Do" }, { id: "in_progress", title: "In Progress" }, { id: "done", title: "Done" }, -]; +] as const; + +type TaskStatus = Task["status"]; + +function isTaskStatus(value: unknown): value is TaskStatus { + return COLUMNS.some((column) => column.id === value); +} export function KanbanBoard() { const [tasks, setTasks] = useState([]); const [activeTask, setActiveTask] = useState(null); - const [socket, setSocket] = useState(null); + const socketRef = useRef | null>(null); const handleEditTask = async (task: Task) => { const newTitle = window.prompt("Edit title:", task.title); @@ -63,13 +72,27 @@ export function KanbanBoard() { } }; + const fetchTasks = async () => { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks`); + if (res.ok) { + const data = await res.json(); + setTasks(data); + } + } catch (error) { + console.error("Failed to fetch tasks", error); + } + }; + useEffect(() => { // Assuming backend runs on 5000 in dev const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; const newSocket = io(apiUrl); - setSocket(newSocket); + socketRef.current = newSocket; - fetchTasks(); + const loadTimer = window.setTimeout(() => { + void fetchTasks(); + }, 0); newSocket.on("task-moved", (movedTask: Task) => { setTasks((prev) => { @@ -97,22 +120,12 @@ export function KanbanBoard() { }); return () => { + window.clearTimeout(loadTimer); newSocket.disconnect(); + socketRef.current = null; }; }, []); - const fetchTasks = async () => { - try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"}/api/tasks`); - if (res.ok) { - const data = await res.json(); - setTasks(data); - } - } catch (error) { - console.error("Failed to fetch tasks", error); - } - }; - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -124,13 +137,13 @@ export function KanbanBoard() { }) ); - const handleDragStart = (event: any) => { + const handleDragStart = (event: DragStartEvent) => { const { active } = event; const task = tasks.find((t) => t.id === active.id); if (task) setActiveTask(task); }; - const handleDragOver = (event: any) => { + const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; if (!over) return; @@ -166,13 +179,14 @@ export function KanbanBoard() { setTasks((tasks) => { const activeIndex = tasks.findIndex((t) => t.id === activeId); const newTasks = [...tasks]; - newTasks[activeIndex].status = overId as any; + if (activeIndex === -1 || !isTaskStatus(overId)) return tasks; + newTasks[activeIndex].status = overId; return arrayMove(newTasks, activeIndex, activeIndex); }); } }; - const handleDragEnd = async (event: any) => { + const handleDragEnd = async (event: DragEndEvent) => { setActiveTask(null); const { active, over } = event; @@ -187,7 +201,8 @@ export function KanbanBoard() { let newStatus = activeTask.status; if (over.data.current?.type === "Column") { - newStatus = over.id as any; + if (!isTaskStatus(over.id)) return; + newStatus = over.id; } else if (over.data.current?.type === "Task") { const overTask = tasks.find((t) => t.id === overId); if (overTask) { @@ -246,9 +261,9 @@ export function KanbanBoard() { ); // Emit socket updates - if (socket) { + if (socketRef.current) { reorderedTasks.forEach((task) => { - socket.emit("task-moved", task); + socketRef.current?.emit("task-moved", task); }); } } catch (error) { diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 95e8745..4fa2c88 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,15 +1,13 @@ "use client"; import { useState } from "react"; +import type React from "react"; import Link from "next/link"; import { Search, TrendingUp, Rocket, AlertTriangle, - FolderKanban, - Users, - MessageSquare, } from "lucide-react"; import { LineChart, @@ -138,7 +136,7 @@ export default function Dashboard() { Productivity Analytics

- Track your team's performance trends over time + Track your team's performance trends over time

@@ -191,6 +189,32 @@ export default function Dashboard() { Key analytics and intelligent recommendations to guide your workflow.

+
+ + Overview + + + Analytics + + + Feed + + + Tasks + +
@@ -207,7 +231,7 @@ export default function Dashboard() {
View detailed report → @@ -223,9 +247,12 @@ export default function Dashboard() { today can further boost efficiency by 10%.

- +
{/* 🚧 PROJECTS IN PROGRESS */} @@ -257,7 +284,7 @@ export default function Dashboard() { Manage projects → @@ -277,6 +304,7 @@ export default function Dashboard() {
{member.name}

{member.name}

@@ -323,7 +351,13 @@ const btnStrong = "rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800 transition"; /* 🔹 Card */ -function Card({ icon, title, link }: any) { +type CardProps = { + icon: React.ReactNode; + title: string; + link: string; +}; + +function Card({ icon, title, link }: CardProps) { return (
{icon}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f09a7ab..724a4e1 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,5 +1,19 @@ @import "tailwindcss"; +@theme { + --color-primary: #0f766e; + --color-primary-dark: #115e59; + --color-primary-light: #eef2e7; + + --color-background: #f5f7f2; + --color-surface: #ffffff; + + --color-text-main: #1d2a20; + --color-text-muted: #5d6d62; + + --color-border-subtle: #dbe4d5; +} + :root { --bg-page: #f5f7f2; --bg-panel: #ffffff; diff --git a/frontend/app/hooks/useAuth.ts b/frontend/app/hooks/useAuth.ts index bd20c7d..3588a12 100644 --- a/frontend/app/hooks/useAuth.ts +++ b/frontend/app/hooks/useAuth.ts @@ -3,9 +3,10 @@ import { useEffect, useState } from "react"; import { supabase } from "../lib/supabase"; import { useRouter } from "next/navigation"; +import type { User } from "@supabase/supabase-js"; export function useAuth() { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const router = useRouter(); useEffect(() => { @@ -40,4 +41,4 @@ export function useAuth() { }, [router]); return user; -} \ No newline at end of file +} diff --git a/frontend/app/insights pages/page.tsx b/frontend/app/insights pages/page.tsx new file mode 100644 index 0000000..8e093c9 --- /dev/null +++ b/frontend/app/insights pages/page.tsx @@ -0,0 +1,77 @@ +"use client" + +const InsightsPage = () => { + const navItems = [ + { + label: 'Overview', + active: true, + }, + { + label: "Analytics", + active: false, + }, + { + label: "Tasks", + active: false, + }, + { + label: "Feed", + active: false, + }, + ]; + return ( +
+
+ + +
+
+
+

+ Project Alpha Evolution +

+

+ Project +
+ Evolution +

+

+ Acomprehensive deep-dive into project development, + velocity trends, and realtime contributor synchronization. +

+
+
+
+ + +
+
+ ) +} + +export default InsightsPage \ No newline at end of file diff --git a/frontend/app/insights/analytics/page.tsx b/frontend/app/insights/analytics/page.tsx new file mode 100644 index 0000000..15bda40 --- /dev/null +++ b/frontend/app/insights/analytics/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import AnalyticsSidebar from "@/app/components/analytics-comp/AnalyticsSideBar"; +import AnalyticsHeader from "@/app/components/analytics-comp/AnalyticsHeader"; +import PerformanceSummary, { + type AnalyticsMetric, + type MemberPerformance, +} from "@/app/components/analytics-comp/PerformanceSummary"; +import MemberAnalytics from "@/app/components/analytics-comp/MemberAnalytics"; +import SprintLeaderboard, { + type LeaderboardSortKey, +} from "@/app/components/analytics-comp/SprintLeaderboard"; +import AnalyticsMobileNav from "@/app/components/analytics-comp/AnalyticsMobileNav"; + +const sprintData: Record = { + "Sprint 42": [ + { id: "alex", name: "Alex Rivera", role: "Lead Frontend Engineer", assigned: 85, completed: 72, reviews: 18, focus: "Frontend", activity: [30, 50, 80, 100, 90, 60, 20, 85], image: "https://i.pravatar.cc/96?img=11" }, + { id: "jordan", name: "Jordan Smith", role: "Backend Engineer", assigned: 60, completed: 58, reviews: 15, focus: "API", activity: [45, 65, 55, 80, 70, 64, 58, 75], image: "https://i.pravatar.cc/96?img=12" }, + { id: "casey", name: "Casey Morgan", role: "Fullstack Developer", assigned: 95, completed: 90, reviews: 24, focus: "Fullstack", activity: [70, 40, 90, 100, 50, 80, 30, 95], image: "https://i.pravatar.cc/96?img=13" }, + { id: "riley", name: "Riley Lee", role: "QA Analyst", assigned: 45, completed: 45, reviews: 11, focus: "QA", activity: [35, 55, 42, 62, 78, 45, 68, 72], image: "https://i.pravatar.cc/96?img=14" }, + { id: "morgan", name: "Morgan Patel", role: "Product Engineer", assigned: 80, completed: 40, reviews: 9, focus: "Product", activity: [25, 35, 52, 48, 60, 42, 38, 50], image: "https://i.pravatar.cc/96?img=15" }, + { id: "quinn", name: "Quinn Taylor", role: "DevOps Engineer", assigned: 70, completed: 68, reviews: 14, focus: "Ops", activity: [55, 68, 72, 75, 62, 74, 80, 78], image: "https://i.pravatar.cc/96?img=16" }, + ], + "Sprint 41": [ + { id: "alex", name: "Alex Rivera", role: "Lead Frontend Engineer", assigned: 76, completed: 70, reviews: 21, focus: "Frontend", activity: [44, 58, 62, 88, 92, 70, 55, 81], image: "https://i.pravatar.cc/96?img=11" }, + { id: "jordan", name: "Jordan Smith", role: "Backend Engineer", assigned: 72, completed: 61, reviews: 12, focus: "API", activity: [40, 48, 65, 72, 68, 71, 52, 64], image: "https://i.pravatar.cc/96?img=12" }, + { id: "casey", name: "Casey Morgan", role: "Fullstack Developer", assigned: 88, completed: 84, reviews: 20, focus: "Fullstack", activity: [58, 74, 82, 95, 76, 86, 62, 91], image: "https://i.pravatar.cc/96?img=13" }, + { id: "riley", name: "Riley Lee", role: "QA Analyst", assigned: 52, completed: 49, reviews: 16, focus: "QA", activity: [62, 60, 70, 66, 78, 74, 72, 80], image: "https://i.pravatar.cc/96?img=14" }, + { id: "morgan", name: "Morgan Patel", role: "Product Engineer", assigned: 66, completed: 59, reviews: 10, focus: "Product", activity: [32, 46, 58, 62, 60, 55, 64, 68], image: "https://i.pravatar.cc/96?img=15" }, + ], +}; + +type AnalyticsResponse = { + sprints: Array<{ + label: string; + members: MemberPerformance[]; + }>; +}; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + +export default function InsightsAnalyticsPage() { + const [data, setData] = useState(sprintData); + const [sprint, setSprint] = useState("Sprint 42"); + const [metric, setMetric] = useState("completed"); + const [selectedMemberId, setSelectedMemberId] = useState("casey"); + const [query, setQuery] = useState(""); + const [sortKey, setSortKey] = useState("score"); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + + async function loadAnalytics() { + setIsLoading(true); + setLoadError(""); + try { + const response = await fetch(`${API_URL}/api/analytics`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error("Failed to load analytics"); + const body = (await response.json()) as AnalyticsResponse; + const nextData = body.sprints.reduce>( + (acc, item) => { + acc[item.label] = item.members; + return acc; + }, + {} + ); + + if (Object.keys(nextData).length > 0) { + setData(nextData); + const firstSprint = Object.keys(nextData)[0]; + setSprint((current) => (nextData[current] ? current : firstSprint)); + setSelectedMemberId((current) => { + const activeMembers = nextData[firstSprint] || []; + return activeMembers.some((member) => member.id === current) + ? current + : activeMembers[0]?.id || current; + }); + } + } catch (error) { + if ((error as Error).name !== "AbortError") { + setLoadError("Showing local analytics because the backend is unavailable."); + } + } finally { + setIsLoading(false); + } + } + + void loadAnalytics(); + return () => controller.abort(); + }, []); + + const members = useMemo( + () => data[sprint] || Object.values(data)[0] || [], + [data, sprint] + ); + const selectedMember = members.find((member) => member.id === selectedMemberId) ?? members[0]; + + const filteredMembers = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return members; + return members.filter((member) => + [member.name, member.role, member.focus].some((value) => + value.toLowerCase().includes(normalizedQuery) + ) + ); + }, [members, query]); + + return ( + <> + { + setSprint(nextSprint); + setSelectedMemberId(data[nextSprint][0]?.id || ""); + }} + /> + +
+
+ {(isLoading || loadError) && ( +
+ {isLoading ? "Refreshing analytics from backend..." : loadError} +
+ )} + + + +
+
+ + + ); +} diff --git a/frontend/app/insights/feed/page.tsx b/frontend/app/insights/feed/page.tsx new file mode 100644 index 0000000..1a16508 --- /dev/null +++ b/frontend/app/insights/feed/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import FeedSidebar from "@/app/components/Feed-comp/FeedSidebar"; +import FeedHeader, { type FeedFilter } from "@/app/components/Feed-comp/FeedHeader"; +import FeedList, { type FeedActivityItem } from "@/app/components/Feed-comp/FeedList"; +import FeedMobileNav from "@/app/components/Feed-comp/FeedMobileNav"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + +const fallbackItems: FeedActivityItem[] = [ + { + id: "fallback-deploy", + type: "code", + actor: "Alex Rivera", + action: "deployed to production", + title: "Production Environment: v2.4.0-rc1", + body: "Successfully deployed 14 services and updated the global edge configuration.", + time: "2 hours ago", + group: "Today", + meta: "Deployment", + image: "https://i.pravatar.cc/96?img=21", + progress: null, + }, + { + id: "fallback-comment", + type: "discussion", + actor: "James Wilson", + action: "commented on a task", + title: "Heatmap spacing", + body: "The insights heatmap needs more padding around the legend on compact displays.", + time: "Yesterday", + group: "Yesterday", + meta: "Discussion", + image: "https://i.pravatar.cc/96?img=22", + progress: null, + }, +]; + +export default function InsightsFeedPage() { + const [items, setItems] = useState(fallbackItems); + const [filter, setFilter] = useState("All"); + const [query, setQuery] = useState(""); + const [visibleCount, setVisibleCount] = useState(6); + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + + async function loadFeed() { + setIsLoading(true); + setStatus(""); + try { + const response = await fetch(`${API_URL}/api/feed`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error("Failed to load feed"); + const body = (await response.json()) as { items: FeedActivityItem[] }; + setItems(body.items.length > 0 ? body.items : fallbackItems); + } catch (error) { + if ((error as Error).name !== "AbortError") { + setStatus("Showing local activity because the backend feed is unavailable."); + } + } finally { + setIsLoading(false); + } + } + + void loadFeed(); + return () => controller.abort(); + }, []); + + const filteredItems = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + return items.filter((item) => { + const matchesFilter = + filter === "All" || + (filter === "Code" && item.type === "code") || + (filter === "Discussion" && item.type === "discussion") || + (filter === "Milestones" && item.type === "milestone"); + const matchesQuery = + !normalizedQuery || + [item.actor, item.action, item.title, item.body, item.meta].some((value) => + String(value || "").toLowerCase().includes(normalizedQuery) + ); + return matchesFilter && matchesQuery; + }); + }, [filter, items, query]); + + const createInsight = async () => { + const title = window.prompt("Insight title"); + if (!title?.trim()) return; + const body = window.prompt("Insight details"); + if (!body?.trim()) return; + + try { + const response = await fetch(`${API_URL}/api/feed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: title.trim(), body: body.trim(), type: "discussion" }), + }); + if (!response.ok) throw new Error("Failed to create insight"); + const result = (await response.json()) as { item: FeedActivityItem }; + setItems((current) => [result.item, ...current]); + setStatus("Insight added to the backend feed."); + } catch { + const localItem: FeedActivityItem = { + id: `local-${Date.now()}`, + type: "discussion", + actor: "You", + action: "created a local insight", + title: title.trim(), + body: body.trim(), + time: "Just now", + group: "Today", + meta: "Local draft", + image: null, + progress: null, + }; + setItems((current) => [localItem, ...current]); + setStatus("Backend was unavailable, so this insight was added locally."); + } + }; + + return ( + <> + + +
+
+ setVisibleCount((count) => count + 4)} + /> +
+
+ + + ); +} diff --git a/frontend/app/insights/layout.tsx b/frontend/app/insights/layout.tsx new file mode 100644 index 0000000..9ee39b6 --- /dev/null +++ b/frontend/app/insights/layout.tsx @@ -0,0 +1,16 @@ +// app/insights/layout.tsx +import React from "react"; +import InsightsSidebar from "@/app/components/InsightsSidebar"; + +export default function InsightsLayout({ + children, +}: { children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/insights/overview/page.tsx b/frontend/app/insights/overview/page.tsx new file mode 100644 index 0000000..77d6ac5 --- /dev/null +++ b/frontend/app/insights/overview/page.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import TopHeader from "@/app/components/Tasks-comp/TopHeader"; +import OverviewMain from "@/app/components/Overview-comp/OverviewMain"; + +export default function InsightsOverviewPage() { + return ( + <> + +
+
+ +
+
+ + ); +} \ No newline at end of file diff --git a/frontend/app/insights/tasks/page.tsx b/frontend/app/insights/tasks/page.tsx new file mode 100644 index 0000000..e4107db --- /dev/null +++ b/frontend/app/insights/tasks/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import TasksHeader from "@/app/components/Tasks-comp/TasksHeader"; +import TasksSidebar from "@/app/components/Tasks-comp/TasksSideBar"; +import TaskPulseBar, { type TaskPulseStage } from "@/app/components/Tasks-comp/TasksPulsebar"; +import TaskFlow, { type TaskFlowNode } from "@/app/components/Tasks-comp/TasksFlow"; +import TaskHistoryTable, { type TaskHistoryRow } from "@/app/components/Tasks-comp/TaskHistoryTable"; +import MobileNav from "@/app/components/Tasks-comp/MobileNav"; + +type TaskInsights = { + summary: { + totalTasks: number; + completedTasks: number; + activeTasks: number; + completionRate: number; + }; + stages: TaskPulseStage[]; + flowNodes: TaskFlowNode[]; + history: TaskHistoryRow[]; +}; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; + +const fallbackInsights: TaskInsights = { + summary: { + totalTasks: 0, + completedTasks: 0, + activeTasks: 0, + completionRate: 0, + }, + stages: [ + { label: "Todo", value: 0, active: false }, + { label: "In Progress", value: 0, active: false }, + { label: "Review", value: 0, active: false }, + { label: "Total Cycle", value: 0, active: false }, + ], + flowNodes: [ + { label: "Todo", sub: "Queue", value: 0, active: false }, + { label: "In Progress", sub: "Active", value: 0, active: false }, + { label: "Review", sub: "Verify", value: 0, active: false }, + { label: "Done", sub: "Archived", value: 0, active: false }, + ], + history: [], +}; + +export default function InsightsTasksPage() { + const [insights, setInsights] = useState(fallbackInsights); + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("Loading backend task insights..."); + + useEffect(() => { + const controller = new AbortController(); + + async function loadTaskInsights() { + try { + const response = await fetch(`${API_URL}/api/insights/tasks`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error("Failed to load task insights"); + const body = (await response.json()) as TaskInsights; + setInsights(body); + setStatus("Synced with backend task data."); + } catch (error) { + if ((error as Error).name !== "AbortError") { + setStatus("Showing local task insights because backend data is unavailable."); + } + } + } + + void loadTaskInsights(); + return () => controller.abort(); + }, []); + + const filteredHistory = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return insights.history; + return insights.history.filter((row) => + [row.task, row.assignee, row.state, row.trigger].some((value) => + value.toLowerCase().includes(normalizedQuery) + ) + ); + }, [insights.history, query]); + + return ( + <> + + +
+
+
+
+
+
+

+ Task Journey Tracking +

+

+ Backend-backed lifecycle analysis for {insights.summary.totalTasks} tasks, {insights.summary.completionRate}% complete. +

+

{status}

+
+
+ + +
+ + + +
+
+
+ + + ); +} diff --git a/frontend/app/lib/navItems.ts b/frontend/app/lib/navItems.ts new file mode 100644 index 0000000..428bdd8 --- /dev/null +++ b/frontend/app/lib/navItems.ts @@ -0,0 +1,21 @@ +export type NavItems = { + label: string; + href: string; +}; + +export const navItems: NavItems[] = [ + { + label: "Overview", href: "/insights/overview" + }, + { + label: "Analytics", href: "/insights/analytics" + }, + { + label: "Feed", href: "/insights/feed" + }, + { + label: "Tasks", href: "/insights/tasks" + }, + +]; + diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index daf3749..2b9ab14 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -16,7 +16,7 @@ export default function Login() { useEffect(() => { const params = new URLSearchParams(window.location.search); - setNextPath(params.get("next")); + queueMicrotask(() => setNextPath(params.get("next"))); }, []); const handleLogin = async () => { @@ -97,4 +97,4 @@ export default function Login() {
); -} \ No newline at end of file +} diff --git a/frontend/app/projects/page.tsx b/frontend/app/projects/page.tsx index 2f048eb..eb10c81 100644 --- a/frontend/app/projects/page.tsx +++ b/frontend/app/projects/page.tsx @@ -6,12 +6,22 @@ import { isSupabaseConfigured, supabase } from "@/app/lib/supabase"; import { useState, useEffect } from "react"; import ProjectDialog from "@/app/components/ProjectDialog"; +type Project = { + id: string; + name: string; + desc: string; + members: number; + due: string | null; + tags: string[]; + createdAt: string; +}; + export default function ProjectsPage() { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [open, setOpen] = useState(false); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); // Debounce search term @@ -86,7 +96,7 @@ export default function ProjectsPage() { setOpen(true); }; - const handleProjectCreated = (newProject: any) => { + const handleProjectCreated = (newProject: Project) => { setProjects((prev) => [ { name: newProject.name || "", @@ -197,4 +207,4 @@ export default function ProjectsPage() { )} ); -} \ No newline at end of file +} diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index d5915a3..c6b7616 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { isSupabaseConfigured, supabase } from "@/app/lib/supabase"; +import { supabase } from "@/app/lib/supabase"; function generatePassword(length = 14): string { const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; @@ -48,7 +48,7 @@ export default function Signup() { useEffect(() => { const params = new URLSearchParams(window.location.search); - setNextPath(params.get("next")); + queueMicrotask(() => setNextPath(params.get("next"))); }, []); const handleGeneratePassword = () => { @@ -107,14 +107,14 @@ export default function Signup() { mobile: mobile.trim(), }), }); - let result: any = {}; - let error: any = null; + let result: { error?: string } = {}; + let error: { message: string } | null = null; try { result = await res.json(); if (result.error){ error = {message: result.error} } - } catch (e) { + } catch { error = {message: "Failed to parse server response."} setErrorMsg("An unexpected error occurred. Please try again."); setLoading(false); @@ -271,4 +271,4 @@ export default function Signup() { ); -} \ No newline at end of file +}