feat: unified activity feed with comments + progress notes
- New /api/activity endpoint returning combined timeline of progress notes and comments across all tasks, sorted chronologically - Activity page now fetches from unified endpoint instead of extracting from task data client-side - Type filter (progress/comment) and status filter on Activity page - Comment entries show author avatars and type badges - 30s auto-refresh on activity feed
This commit is contained in:
@@ -5,6 +5,7 @@ import { adminRoutes } from "./routes/admin";
|
|||||||
import { projectRoutes } from "./routes/projects";
|
import { projectRoutes } from "./routes/projects";
|
||||||
import { chatRoutes } from "./routes/chat";
|
import { chatRoutes } from "./routes/chat";
|
||||||
import { commentRoutes } from "./routes/comments";
|
import { commentRoutes } from "./routes/comments";
|
||||||
|
import { activityRoutes } from "./routes/activity";
|
||||||
import { auth } from "./lib/auth";
|
import { auth } from "./lib/auth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { tasks, users } from "./db/schema";
|
import { tasks, users } from "./db/schema";
|
||||||
@@ -117,6 +118,7 @@ const app = new Elysia()
|
|||||||
|
|
||||||
.use(taskRoutes)
|
.use(taskRoutes)
|
||||||
.use(commentRoutes)
|
.use(commentRoutes)
|
||||||
|
.use(activityRoutes)
|
||||||
.use(projectRoutes)
|
.use(projectRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
.use(chatRoutes)
|
.use(chatRoutes)
|
||||||
|
|||||||
105
backend/src/routes/activity.ts
Normal file
105
backend/src/routes/activity.ts
Normal file
@@ -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<string, string | undefined>) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTasks } from "../hooks/useTasks";
|
|
||||||
import type { Task, ProgressNote } from "../lib/types";
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||||
@@ -26,55 +24,89 @@ function formatDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityItem {
|
interface ActivityItem {
|
||||||
task: Task;
|
type: "progress" | "comment";
|
||||||
note: ProgressNote;
|
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() {
|
export function ActivityPage() {
|
||||||
const { tasks, loading } = useTasks(15000);
|
const [items, setItems] = useState<ActivityItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [_total, setTotal] = useState(0);
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
|
||||||
const allActivity = useMemo(() => {
|
const fetchActivity = useCallback(async () => {
|
||||||
const items: ActivityItem[] = [];
|
try {
|
||||||
for (const task of tasks) {
|
const res = await fetch("/api/activity?limit=200", { credentials: "include" });
|
||||||
if (task.progressNotes) {
|
if (!res.ok) throw new Error("Failed to fetch activity");
|
||||||
for (const note of task.progressNotes) {
|
const data = await res.json();
|
||||||
items.push({ task, note });
|
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(() => {
|
useEffect(() => {
|
||||||
const filtered =
|
fetchActivity();
|
||||||
filter === "all"
|
const interval = setInterval(fetchActivity, 30000);
|
||||||
? allActivity
|
return () => clearInterval(interval);
|
||||||
: allActivity.filter((a) => a.task.status === filter);
|
}, [fetchActivity]);
|
||||||
|
|
||||||
const groups: { date: string; items: ActivityItem[] }[] = [];
|
// Apply filters
|
||||||
let currentDate = "";
|
const filtered = items.filter((item) => {
|
||||||
for (const item of filtered) {
|
if (filter !== "all" && item.taskStatus !== filter) return false;
|
||||||
const d = new Date(item.note.timestamp).toLocaleDateString("en-US", {
|
if (typeFilter !== "all" && item.type !== typeFilter) return false;
|
||||||
weekday: "long",
|
return true;
|
||||||
month: "long",
|
});
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
// Group by date
|
||||||
});
|
const grouped: ActivityGroup[] = [];
|
||||||
if (d !== currentDate) {
|
let currentDate = "";
|
||||||
currentDate = d;
|
for (const item of filtered) {
|
||||||
groups.push({ date: d, items: [] });
|
const d = new Date(item.timestamp).toLocaleDateString("en-US", {
|
||||||
}
|
weekday: "long",
|
||||||
groups[groups.length - 1].items.push(item);
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
if (d !== currentDate) {
|
||||||
|
currentDate = d;
|
||||||
|
grouped.push({ date: d, items: [] });
|
||||||
}
|
}
|
||||||
return groups;
|
grouped[grouped.length - 1].items.push(item);
|
||||||
}, [allActivity, filter]);
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
|
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||||
Loading activity...
|
Loading activity...
|
||||||
@@ -85,33 +117,54 @@ export function ActivityPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4">
|
||||||
<div>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📝 Activity Log</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📝 Activity Log</h1>
|
||||||
{allActivity.length} updates across {tasks.length} tasks
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
</p>
|
{progressCount} updates · {commentCount} comments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="text-xs border border-gray-200 dark:border-gray-700 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="queued">Queued</option>
|
||||||
|
<option value="blocked">Blocked</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="text-xs border border-gray-200 dark:border-gray-700 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="all">All Types</option>
|
||||||
|
<option value="progress">🔨 Progress Notes</option>
|
||||||
|
<option value="comment">💬 Comments</option>
|
||||||
|
</select>
|
||||||
|
{(filter !== "all" || typeFilter !== "all") && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setFilter("all"); setTypeFilter("all"); }}
|
||||||
|
className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select
|
|
||||||
value={filter}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
|
||||||
>
|
|
||||||
<option value="all">All Tasks</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="completed">Completed</option>
|
|
||||||
<option value="queued">Queued</option>
|
|
||||||
<option value="blocked">Blocked</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
|
||||||
{groupedActivity.length === 0 ? (
|
{grouped.length === 0 ? (
|
||||||
<div className="text-center text-gray-400 dark:text-gray-500 py-12">No activity found</div>
|
<div className="text-center text-gray-400 dark:text-gray-500 py-12">No activity found</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedActivity.map((group) => (
|
{grouped.map((group) => (
|
||||||
<div key={group.date}>
|
<div key={group.date}>
|
||||||
<div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm py-2 mb-3">
|
<div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm py-2 mb-3">
|
||||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400">{group.date}</h2>
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400">{group.date}</h2>
|
||||||
@@ -119,11 +172,21 @@ export function ActivityPage() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{group.items.map((item, i) => (
|
{group.items.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.task.id}-${i}`}
|
key={`${item.taskId}-${item.type}-${i}`}
|
||||||
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white dark:hover:bg-gray-900 transition group"
|
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white dark:hover:bg-gray-900 transition group"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center pt-1.5">
|
<div className="flex flex-col items-center pt-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0" />
|
{item.type === "comment" ? (
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0 ${
|
||||||
|
item.authorId === "hammer"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: avatarColor(item.authorName || "?")
|
||||||
|
}`}>
|
||||||
|
{item.authorId === "hammer" ? "🔨" : (item.authorName || "?").charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0 mt-1.5" />
|
||||||
|
)}
|
||||||
{i < group.items.length - 1 && (
|
{i < group.items.length - 1 && (
|
||||||
<div className="w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
|
<div className="w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
|
||||||
)}
|
)}
|
||||||
@@ -132,23 +195,35 @@ export function ActivityPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
to={`/task/HQ-${item.task.taskNumber}`}
|
to={`/task/HQ-${item.taskNumber}`}
|
||||||
className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 dark:hover:bg-amber-900/50 transition"
|
className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 dark:hover:bg-amber-900/50 transition"
|
||||||
>
|
>
|
||||||
HQ-{item.task.taskNumber}
|
HQ-{item.taskNumber}
|
||||||
</Link>
|
</Link>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||||
|
item.type === "comment"
|
||||||
|
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
|
||||||
|
: "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
|
||||||
|
}`}>
|
||||||
|
{item.type === "comment" ? "💬 comment" : "🔨 progress"}
|
||||||
|
</span>
|
||||||
|
{item.type === "comment" && item.authorName && (
|
||||||
|
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
by {item.authorName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||||
{formatDate(item.note.timestamp)}
|
{formatDate(item.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-gray-300 dark:text-gray-600">
|
<span className="text-[10px] text-gray-300 dark:text-gray-600">
|
||||||
({timeAgo(item.note.timestamp)})
|
({timeAgo(item.timestamp)})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||||
{item.note.note}
|
{item.type === "comment" ? item.content : item.note}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 truncate">
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 truncate">
|
||||||
{item.task.title}
|
{item.taskTitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user