diff --git a/backend/src/index.ts b/backend/src/index.ts index cdaa612..2a5d4e6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,6 +5,7 @@ import { adminRoutes } from "./routes/admin"; import { projectRoutes } from "./routes/projects"; import { chatRoutes } from "./routes/chat"; import { commentRoutes } from "./routes/comments"; +import { activityRoutes } from "./routes/activity"; import { auth } from "./lib/auth"; import { db } from "./db"; import { tasks, users } from "./db/schema"; @@ -117,6 +118,7 @@ const app = new Elysia() .use(taskRoutes) .use(commentRoutes) + .use(activityRoutes) .use(projectRoutes) .use(adminRoutes) .use(chatRoutes) diff --git a/backend/src/routes/activity.ts b/backend/src/routes/activity.ts new file mode 100644 index 0000000..19cc104 --- /dev/null +++ b/backend/src/routes/activity.ts @@ -0,0 +1,105 @@ +import { Elysia, t } from "elysia"; +import { db } from "../db"; +import { tasks, taskComments } from "../db/schema"; +import { desc, sql } from "drizzle-orm"; +import { auth } from "../lib/auth"; + +const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; + +async function requireSessionOrBearer(request: Request, headers: Record) { + const authHeader = headers["authorization"]; + if (authHeader === `Bearer ${BEARER_TOKEN}`) return; + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (session?.user) return; + } catch {} + throw new Error("Unauthorized"); +} + +export interface ActivityFeedItem { + type: "progress" | "comment"; + timestamp: string; + taskId: string; + taskNumber: number | null; + taskTitle: string; + taskStatus: string; + // For progress notes + note?: string; + // For comments + commentId?: string; + authorName?: string; + authorId?: string | null; + content?: string; +} + +export const activityRoutes = new Elysia({ prefix: "/api/activity" }) + .onError(({ error, set }) => { + const msg = (error as any)?.message || String(error); + if (msg === "Unauthorized") { + set.status = 401; + return { error: "Unauthorized" }; + } + set.status = 500; + return { error: "Internal server error" }; + }) + + // GET /api/activity โ€” unified feed of progress notes + comments + .get("/", async ({ request, headers, query }) => { + await requireSessionOrBearer(request, headers); + + const limit = Math.min(Number(query.limit) || 50, 200); + + // Fetch all tasks with progress notes + const allTasks = await db.select().from(tasks); + + // Collect progress note items + const items: ActivityFeedItem[] = []; + for (const task of allTasks) { + const notes = (task.progressNotes || []) as { timestamp: string; note: string }[]; + for (const note of notes) { + items.push({ + type: "progress", + timestamp: note.timestamp, + taskId: task.id, + taskNumber: task.taskNumber, + taskTitle: task.title, + taskStatus: task.status, + note: note.note, + }); + } + } + + // Fetch all comments + const allComments = await db + .select() + .from(taskComments) + .orderBy(desc(taskComments.createdAt)); + + // Build task lookup for comment items + const taskMap = new Map(allTasks.map(t => [t.id, t])); + + for (const comment of allComments) { + const task = taskMap.get(comment.taskId); + if (!task) continue; + items.push({ + type: "comment", + timestamp: comment.createdAt.toISOString(), + taskId: task.id, + taskNumber: task.taskNumber, + taskTitle: task.title, + taskStatus: task.status, + commentId: comment.id, + authorName: comment.authorName, + authorId: comment.authorId, + content: comment.content, + }); + } + + // Sort by timestamp descending, take limit + items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return { + items: items.slice(0, limit), + total: items.length, + }; + }); diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx index 653f140..929ea05 100644 --- a/frontend/src/pages/ActivityPage.tsx +++ b/frontend/src/pages/ActivityPage.tsx @@ -1,7 +1,5 @@ -import { useMemo, useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Link } from "react-router-dom"; -import { useTasks } from "../hooks/useTasks"; -import type { Task, ProgressNote } from "../lib/types"; function timeAgo(dateStr: string): string { const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); @@ -26,55 +24,89 @@ function formatDate(dateStr: string): string { } interface ActivityItem { - task: Task; - note: ProgressNote; + type: "progress" | "comment"; + timestamp: string; + taskId: string; + taskNumber: number | null; + taskTitle: string; + taskStatus: string; + note?: string; + commentId?: string; + authorName?: string; + authorId?: string | null; + content?: string; +} + +interface ActivityGroup { + date: string; + items: ActivityItem[]; +} + +function avatarColor(name: string): string { + const colors = [ + "bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500", + "bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500", + ]; + let hash = 0; + for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); + return colors[Math.abs(hash) % colors.length]; } export function ActivityPage() { - const { tasks, loading } = useTasks(15000); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [_total, setTotal] = useState(0); const [filter, setFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); - const allActivity = useMemo(() => { - const items: ActivityItem[] = []; - for (const task of tasks) { - if (task.progressNotes) { - for (const note of task.progressNotes) { - items.push({ task, note }); - } - } + const fetchActivity = useCallback(async () => { + try { + const res = await fetch("/api/activity?limit=200", { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch activity"); + const data = await res.json(); + setItems(data.items || []); + setTotal(data.total || 0); + } catch (e) { + console.error("Failed to fetch activity:", e); + } finally { + setLoading(false); } - items.sort( - (a, b) => - new Date(b.note.timestamp).getTime() - new Date(a.note.timestamp).getTime() - ); - return items; - }, [tasks]); + }, []); - const groupedActivity = useMemo(() => { - const filtered = - filter === "all" - ? allActivity - : allActivity.filter((a) => a.task.status === filter); + useEffect(() => { + fetchActivity(); + const interval = setInterval(fetchActivity, 30000); + return () => clearInterval(interval); + }, [fetchActivity]); - const groups: { date: string; items: ActivityItem[] }[] = []; - let currentDate = ""; - for (const item of filtered) { - const d = new Date(item.note.timestamp).toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - year: "numeric", - }); - if (d !== currentDate) { - currentDate = d; - groups.push({ date: d, items: [] }); - } - groups[groups.length - 1].items.push(item); + // Apply filters + const filtered = items.filter((item) => { + if (filter !== "all" && item.taskStatus !== filter) return false; + if (typeFilter !== "all" && item.type !== typeFilter) return false; + return true; + }); + + // Group by date + const grouped: ActivityGroup[] = []; + let currentDate = ""; + for (const item of filtered) { + const d = new Date(item.timestamp).toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); + if (d !== currentDate) { + currentDate = d; + grouped.push({ date: d, items: [] }); } - return groups; - }, [allActivity, filter]); + grouped[grouped.length - 1].items.push(item); + } - if (loading && tasks.length === 0) { + const commentCount = items.filter(i => i.type === "comment").length; + const progressCount = items.filter(i => i.type === "progress").length; + + if (loading) { return (
Loading activity... @@ -85,33 +117,54 @@ export function ActivityPage() { return (
-
-
-

๐Ÿ“ Activity Log

-

- {allActivity.length} updates across {tasks.length} tasks -

+
+
+
+

๐Ÿ“ Activity Log

+

+ {progressCount} updates ยท {commentCount} comments +

+
+
+
+ + + {(filter !== "all" || typeFilter !== "all") && ( + + )}
-
- {groupedActivity.length === 0 ? ( + {grouped.length === 0 ? (
No activity found
) : (
- {groupedActivity.map((group) => ( + {grouped.map((group) => (

{group.date}

@@ -119,11 +172,21 @@ export function ActivityPage() {
{group.items.map((item, i) => (
-
+ {item.type === "comment" ? ( +
+ {item.authorId === "hammer" ? "๐Ÿ”จ" : (item.authorName || "?").charAt(0).toUpperCase()} +
+ ) : ( +
+ )} {i < group.items.length - 1 && (
)} @@ -132,23 +195,35 @@ export function ActivityPage() {
- HQ-{item.task.taskNumber} + HQ-{item.taskNumber} + + {item.type === "comment" ? "๐Ÿ’ฌ comment" : "๐Ÿ”จ progress"} + + {item.type === "comment" && item.authorName && ( + + by {item.authorName} + + )} - {formatDate(item.note.timestamp)} + {formatDate(item.timestamp)} - ({timeAgo(item.note.timestamp)}) + ({timeAgo(item.timestamp)})

- {item.note.note} + {item.type === "comment" ? item.content : item.note}

- {item.task.title} + {item.taskTitle}