From 27b875519307cde000a5de5f61a3186c1f527d1e Mon Sep 17 00:00:00 2001 From: king-aj-the-first <277238059+king-aj-the-first@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:42:35 +0100 Subject: [PATCH] feat(frontend): add user activity timeline with chronological history and user isolation --- .../app/(dashboard)/user/overview/page.tsx | 25 +- frontend/src/app/api/activities/route.ts | 141 ++++++++++ .../src/components/UserActivityTimeline.tsx | 266 ++++++++++++++++++ frontend/src/hooks/useActivityTimeline.ts | 112 ++++++++ frontend/src/lib/activities-api.test.ts | 70 +++++ frontend/src/lib/activity-store.test.ts | 145 ++++++++++ frontend/src/lib/activity-store.ts | 133 +++++++++ frontend/src/test/regression.test.ts | 22 ++ frontend/src/types/activity.ts | 47 ++++ 9 files changed, 959 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/api/activities/route.ts create mode 100644 frontend/src/components/UserActivityTimeline.tsx create mode 100644 frontend/src/hooks/useActivityTimeline.ts create mode 100644 frontend/src/lib/activities-api.test.ts create mode 100644 frontend/src/lib/activity-store.test.ts create mode 100644 frontend/src/lib/activity-store.ts create mode 100644 frontend/src/types/activity.ts diff --git a/frontend/src/app/(dashboard)/user/overview/page.tsx b/frontend/src/app/(dashboard)/user/overview/page.tsx index 4043e81..71f1d16 100644 --- a/frontend/src/app/(dashboard)/user/overview/page.tsx +++ b/frontend/src/app/(dashboard)/user/overview/page.tsx @@ -1,12 +1,32 @@ "use client"; -import React from "react"; +import React, { useState, useEffect } from "react"; import ActiveGroupsWidget from "./components/ActiveGroupsWidget"; import ContributorProfileCard from "./components/ContributorProfileCard"; +import { UserActivityTimeline } from "@/components/UserActivityTimeline"; import { motion } from "framer-motion"; import { LayoutDashboard } from "lucide-react"; +import { getPublicKey } from "@/hooks/stellar-wallets-kit"; export default function OverviewPage() { + const [walletAddress, setWalletAddress] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function checkWallet() { + const key = await getPublicKey(); + if (!cancelled) { + setWalletAddress(key ?? "demo-user"); + } + } + + void checkWallet(); + return () => { + cancelled = true; + }; + }, []); + return (
{/* Page Header */} @@ -24,7 +44,7 @@ export default function OverviewPage() { Overview

- Welcome back — here's a snapshot of your groups + Welcome back — here's a snapshot of your groups & activities

@@ -32,6 +52,7 @@ export default function OverviewPage() { {/* Widgets Grid */}
+
diff --git a/frontend/src/app/api/activities/route.ts b/frontend/src/app/api/activities/route.ts new file mode 100644 index 0000000..f181e28 --- /dev/null +++ b/frontend/src/app/api/activities/route.ts @@ -0,0 +1,141 @@ +import { buildNoStoreJson } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + listUserActivities, + recordActivity, + seedDefaultActivitiesIfEmpty, +} from "@/lib/activity-store"; +import type { ActivityType } from "@/types/activity"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { searchParams } = new URL(request.url); + const userId = (searchParams.get("userId") ?? "").trim(); + + if (!userId) { + return buildNoStoreJson( + { + ok: false, + error: "userId query parameter is required.", + }, + 400, + rateLimitHeaders, + ); + } + + // Seed default activities for demo user if none exist yet + seedDefaultActivitiesIfEmpty(userId); + + const type = (searchParams.get("type") as ActivityType) || undefined; + const limitParam = searchParams.get("limit"); + const offsetParam = searchParams.get("offset"); + + const limit = limitParam ? parseInt(limitParam, 10) : 50; + const offset = offsetParam ? parseInt(offsetParam, 10) : 0; + + const result = listUserActivities({ + userId, + type, + limit, + offset, + }); + + return buildNoStoreJson( + { + ok: true, + activities: result.activities, + total: result.total, + }, + 200, + rateLimitHeaders, + ); +} + +export async function POST(request: Request) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return buildNoStoreJson( + { + ok: false, + error: "Request body must be valid JSON.", + }, + 400, + rateLimitHeaders, + ); + } + + if (!body || typeof body !== "object") { + return buildNoStoreJson( + { + ok: false, + error: "Invalid activity payload.", + }, + 400, + rateLimitHeaders, + ); + } + + const payload = body as Record; + const userId = String(payload.userId ?? "").trim(); + const type = String(payload.type ?? "") as ActivityType; + const title = String(payload.title ?? "").trim(); + const description = String(payload.description ?? "").trim(); + const metadata = (payload.metadata ?? undefined) as Record | undefined; + + if (!userId) { + return buildNoStoreJson( + { ok: false, error: "userId is required." }, + 400, + rateLimitHeaders, + ); + } + + if (!type) { + return buildNoStoreJson( + { ok: false, error: "type is required." }, + 400, + rateLimitHeaders, + ); + } + + if (!title) { + return buildNoStoreJson( + { ok: false, error: "title is required." }, + 400, + rateLimitHeaders, + ); + } + + const activity = recordActivity({ + userId, + type, + title, + description, + metadata, + }); + + return buildNoStoreJson( + { + ok: true, + activity, + }, + 201, + rateLimitHeaders, + ); +} diff --git a/frontend/src/components/UserActivityTimeline.tsx b/frontend/src/components/UserActivityTimeline.tsx new file mode 100644 index 0000000..6268eb9 --- /dev/null +++ b/frontend/src/components/UserActivityTimeline.tsx @@ -0,0 +1,266 @@ +"use client"; + +import React, { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + Bookmark, + Send, + UserCheck, + Award, + FileCheck2, + Clock, + Filter, + RefreshCw, + ExternalLink, + Layers, +} from "lucide-react"; +import type { ActivityRecord, ActivityType } from "@/types/activity"; +import { useActivityTimeline } from "@/hooks/useActivityTimeline"; + +const ACTIVITY_ICONS: Record< + ActivityType, + { + icon: React.ElementType; + color: string; + bg: string; + border: string; + } +> = { + grant_saved: { + icon: Bookmark, + color: "text-amber-400", + bg: "bg-amber-400/10", + border: "border-amber-400/20", + }, + grant_unsaved: { + icon: Bookmark, + color: "text-slate-400", + bg: "bg-slate-400/10", + border: "border-slate-400/20", + }, + application_submitted: { + icon: Send, + color: "text-[#8B92E8]", + bg: "bg-[#5B63D6]/15", + border: "border-[#5B63D6]/30", + }, + application_status_updated: { + icon: FileCheck2, + color: "text-emerald-400", + bg: "bg-emerald-400/10", + border: "border-emerald-400/20", + }, + account_updated: { + icon: UserCheck, + color: "text-cyan-400", + bg: "bg-cyan-400/10", + border: "border-cyan-400/20", + }, + profile_updated: { + icon: UserCheck, + color: "text-cyan-400", + bg: "bg-cyan-400/10", + border: "border-cyan-400/20", + }, + bounty_created: { + icon: Award, + color: "text-indigo-400", + bg: "bg-indigo-400/10", + border: "border-indigo-400/20", + }, + submission_created: { + icon: Send, + color: "text-purple-400", + bg: "bg-purple-400/10", + border: "border-purple-400/20", + }, + submission_reviewed: { + icon: FileCheck2, + color: "text-emerald-400", + bg: "bg-emerald-400/10", + border: "border-emerald-400/20", + }, + comment_posted: { + icon: Layers, + color: "text-blue-400", + bg: "bg-blue-400/10", + border: "border-blue-400/20", + }, +}; + +function formatTimestamp(isoString: string): string { + try { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + } catch { + return isoString; + } +} + +export interface UserActivityTimelineProps { + userId?: string | null; + className?: string; + limit?: number; +} + +export function UserActivityTimeline({ + userId = "current-user", + className = "", +}: UserActivityTimelineProps) { + const [filterType, setFilterType] = useState("all"); + const activeType = filterType === "all" ? undefined : filterType; + + const { activities, isLoading, error, refetch } = useActivityTimeline({ + userId: userId || "default-user", + type: activeType, + }); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Activity Timeline

+

+ Your recent applications, saved grants, and account actions +

+
+
+ + {/* Filter and Refresh */} +
+
+ + +
+ + +
+
+ + {/* Timeline List */} +
+ {isLoading ? ( +
+ Loading your timeline… +
+ ) : error ? ( +
{error}
+ ) : activities.length === 0 ? ( +
+ No activities recorded yet. +
+ ) : ( +
+ + {activities.map((activity, idx) => { + const config = ACTIVITY_ICONS[activity.type] || ACTIVITY_ICONS.account_updated; + const Icon = config.icon; + + return ( + + {/* Timeline Node Dot */} +
+ +
+ + {/* Content Card */} +
+
+

+ {activity.title} +

+ + {formatTimestamp(activity.timestamp)} + +
+

+ {activity.description} +

+ + {/* Metadata badges if present */} + {activity.metadata && ( +
+ {activity.metadata.grantName && ( + + + {String(activity.metadata.grantName)} + + )} + {activity.metadata.status && ( + + {String(activity.metadata.status)} + + )} + {activity.metadata.taskTitle && ( + + + {String(activity.metadata.taskTitle)} + + )} + {Array.isArray(activity.metadata.updatedFields) && ( + + Updated: {activity.metadata.updatedFields.join(", ")} + + )} +
+ )} +
+
+ ); + })} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useActivityTimeline.ts b/frontend/src/hooks/useActivityTimeline.ts new file mode 100644 index 0000000..67d3d0a --- /dev/null +++ b/frontend/src/hooks/useActivityTimeline.ts @@ -0,0 +1,112 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { ActivityRecord, ActivityType } from "@/types/activity"; + +export interface UseActivityTimelineOptions { + userId: string | null; + type?: ActivityType; + pollInterval?: number; +} + +export function useActivityTimeline({ + userId, + type, + pollInterval = 10000, +}: UseActivityTimelineOptions) { + const [activities, setActivities] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchActivities = useCallback(async () => { + if (!userId) { + setActivities([]); + setTotal(0); + setIsLoading(false); + return; + } + + try { + const params = new URLSearchParams({ userId }); + if (type) { + params.append("type", type); + } + + const res = await fetch(`/api/activities?${params.toString()}`); + if (!res.ok) { + throw new Error(`Failed to load activities (${res.status})`); + } + + const data = (await res.json()) as { + ok: boolean; + activities: ActivityRecord[]; + total: number; + }; + + if (data.ok) { + setActivities(data.activities); + setTotal(data.total); + setError(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load activity timeline"); + } finally { + setIsLoading(false); + } + }, [userId, type]); + + useEffect(() => { + setIsLoading(true); + void fetchActivities(); + + if (pollInterval > 0 && userId) { + const interval = setInterval(fetchActivities, pollInterval); + return () => clearInterval(interval); + } + }, [fetchActivities, pollInterval, userId]); + + const addActivity = async ( + activityType: ActivityType, + title: string, + description: string, + metadata?: Record, + ) => { + if (!userId) return null; + + try { + const res = await fetch("/api/activities", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId, + type: activityType, + title, + description, + metadata, + }), + }); + + if (!res.ok) throw new Error("Failed to record activity"); + + const data = (await res.json()) as { ok: boolean; activity: ActivityRecord }; + if (data.ok) { + setActivities((prev) => [data.activity, ...prev]); + setTotal((prev) => prev + 1); + return data.activity; + } + } catch (err) { + console.error("Error adding activity:", err); + } + return null; + }; + + return { + activities, + total, + isLoading, + error, + refetch: fetchActivities, + addActivity, + }; +} diff --git a/frontend/src/lib/activities-api.test.ts b/frontend/src/lib/activities-api.test.ts new file mode 100644 index 0000000..aa2ed5c --- /dev/null +++ b/frontend/src/lib/activities-api.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { GET, POST } from "@/app/api/activities/route"; +import { resetActivityStore } from "@/lib/activity-store"; + +describe("Activities API", () => { + beforeEach(() => { + resetActivityStore(); + }); + + it("returns 400 if userId query parameter is missing on GET", async () => { + const req = new Request("http://localhost/api/activities"); + const res = await GET(req); + expect(res.status).toBe(400); + + const body = await res.json(); + expect(body.ok).toBe(false); + expect(body.error).toContain("userId query parameter is required"); + }); + + it("returns user activities and seeds default items on GET", async () => { + const req = new Request("http://localhost/api/activities?userId=user-123"); + const res = await GET(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.activities.length).toBeGreaterThan(0); + expect(body.total).toBe(body.activities.length); + }); + + it("records a new activity via POST and returns 201", async () => { + const payload = { + userId: "user-456", + type: "grant_saved", + title: "Saved Grant XYZ", + description: "Added to saved grants list", + metadata: { grantId: "grant-xyz" }, + }; + + const postReq = new Request("http://localhost/api/activities", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const postRes = await POST(postReq); + expect(postRes.status).toBe(201); + const postBody = await postRes.json(); + expect(postBody.ok).toBe(true); + expect(postBody.activity.title).toBe("Saved Grant XYZ"); + expect(postBody.activity.userId).toBe("user-456"); + + // Fetch and verify it appears first + const getReq = new Request("http://localhost/api/activities?userId=user-456"); + const getRes = await GET(getReq); + const getBody = await getRes.json(); + expect(getBody.activities[0].title).toBe("Saved Grant XYZ"); + }); + + it("returns 400 for invalid payload on POST", async () => { + const postReq = new Request("http://localhost/api/activities", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: "user-789" }), // Missing type and title + }); + + const postRes = await POST(postReq); + expect(postRes.status).toBe(400); + }); +}); diff --git a/frontend/src/lib/activity-store.test.ts b/frontend/src/lib/activity-store.test.ts new file mode 100644 index 0000000..5df1112 --- /dev/null +++ b/frontend/src/lib/activity-store.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + recordActivity, + listUserActivities, + resetActivityStore, + seedDefaultActivitiesIfEmpty, +} from "./activity-store"; + +describe("activity-store", () => { + beforeEach(() => { + resetActivityStore(); + }); + + it("records activities and retrieves them in reverse chronological order", () => { + const user = "GA123"; + const t1 = new Date(2026, 7, 30, 10, 0, 0); + const t2 = new Date(2026, 7, 30, 11, 0, 0); + const t3 = new Date(2026, 7, 30, 12, 0, 0); + + recordActivity( + { + userId: user, + type: "grant_saved", + title: "Saved Grant 1", + description: "First saved grant", + }, + t1, + ); + + recordActivity( + { + userId: user, + type: "application_submitted", + title: "Submitted Application", + description: "Applied for bounty grant", + }, + t2, + ); + + recordActivity( + { + userId: user, + type: "account_updated", + title: "Updated Profile", + description: "Updated bio", + }, + t3, + ); + + const result = listUserActivities({ userId: user }); + expect(result.total).toBe(3); + expect(result.activities[0].title).toBe("Updated Profile"); + expect(result.activities[1].title).toBe("Submitted Application"); + expect(result.activities[2].title).toBe("Saved Grant 1"); + }); + + it("isolates activities strictly per user (users only see their own activity)", () => { + const userA = "GA123"; + const userB = "GB456"; + + recordActivity({ + userId: userA, + type: "grant_saved", + title: "User A Grant", + description: "Only for A", + }); + + recordActivity({ + userId: userB, + type: "application_submitted", + title: "User B App", + description: "Only for B", + }); + + const userAResult = listUserActivities({ userId: userA }); + expect(userAResult.total).toBe(1); + expect(userAResult.activities[0].title).toBe("User A Grant"); + + const userBResult = listUserActivities({ userId: userB }); + expect(userBResult.total).toBe(1); + expect(userBResult.activities[0].title).toBe("User B App"); + }); + + it("filters activities by activity type", () => { + const user = "GA123"; + + recordActivity({ + userId: user, + type: "grant_saved", + title: "Saved Grant", + description: "Watchlist item", + }); + + recordActivity({ + userId: user, + type: "application_submitted", + title: "Grant Application", + description: "Submitted to foundation", + }); + + const savedGrants = listUserActivities({ userId: user, type: "grant_saved" }); + expect(savedGrants.total).toBe(1); + expect(savedGrants.activities[0].type).toBe("grant_saved"); + + const applications = listUserActivities({ userId: user, type: "application_submitted" }); + expect(applications.total).toBe(1); + expect(applications.activities[0].type).toBe("application_submitted"); + }); + + it("handles pagination with limit and offset", () => { + const user = "GA123"; + for (let i = 1; i <= 5; i++) { + recordActivity( + { + userId: user, + type: "grant_saved", + title: `Grant ${i}`, + description: `Desc ${i}`, + }, + new Date(2026, 7, 30, 10, i, 0), + ); + } + + const page1 = listUserActivities({ userId: user, limit: 2, offset: 0 }); + expect(page1.activities).toHaveLength(2); + expect(page1.total).toBe(5); + expect(page1.activities[0].title).toBe("Grant 5"); + + const page2 = listUserActivities({ userId: user, limit: 2, offset: 2 }); + expect(page2.activities).toHaveLength(2); + expect(page2.activities[0].title).toBe("Grant 3"); + }); + + it("seeds default activities when empty", () => { + const user = "GNEWUSER"; + seedDefaultActivitiesIfEmpty(user); + + const result = listUserActivities({ userId: user }); + expect(result.total).toBeGreaterThan(0); + const types = result.activities.map((a) => a.type); + expect(types).toContain("application_submitted"); + expect(types).toContain("grant_saved"); + expect(types).toContain("account_updated"); + }); +}); diff --git a/frontend/src/lib/activity-store.ts b/frontend/src/lib/activity-store.ts new file mode 100644 index 0000000..5125d88 --- /dev/null +++ b/frontend/src/lib/activity-store.ts @@ -0,0 +1,133 @@ +import type { + ActivityRecord, + ActivityType, + CreateActivityInput, + ListActivitiesQuery, +} from "@/types/activity"; + +const activities = new Map(); +let nextActivityId = 1; + +/** + * Record a user activity + */ +export function recordActivity( + input: CreateActivityInput, + now: Date = new Date(), +): ActivityRecord { + const record: ActivityRecord = { + id: String(nextActivityId++), + userId: input.userId.trim(), + type: input.type, + title: input.title.trim(), + description: input.description.trim(), + timestamp: now.toISOString(), + metadata: input.metadata, + }; + + activities.set(record.id, record); + return record; +} + +/** + * List activities exclusively belonging to `userId`, sorted in reverse chronological order (newest first). + */ +export function listUserActivities(query: ListActivitiesQuery): { + activities: ActivityRecord[]; + total: number; +} { + const targetUserId = query.userId.trim(); + if (!targetUserId) { + return { activities: [], total: 0 }; + } + + const userFiltered = Array.from(activities.values()).filter((act) => { + // Strictly isolate activity to the user + if (act.userId !== targetUserId) { + return false; + } + if (query.type && act.type !== query.type) { + return false; + } + return true; + }); + + // Sort chronologically (newest first) + userFiltered.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + + const total = userFiltered.length; + const offset = query.offset && query.offset > 0 ? query.offset : 0; + const limit = query.limit && query.limit > 0 ? query.limit : 50; + + const paginated = userFiltered.slice(offset, offset + limit); + + return { + activities: paginated, + total, + }; +} + +/** + * Seed initial mock activities for a user if empty (useful for default demos/dashboard) + */ +export function seedDefaultActivitiesIfEmpty(userId: string) { + const existing = listUserActivities({ userId }); + if (existing.total > 0) return; + + const now = Date.now(); + const sampleActivities: Array<{ + type: ActivityType; + title: string; + description: string; + offsetMinutes: number; + metadata?: ActivityRecord["metadata"]; + }> = [ + { + type: "application_submitted", + title: "Submitted grant application", + description: "Applied for Stellar Community Fund Round 28", + offsetMinutes: 15, + metadata: { grantId: "grant-scf-28", grantName: "Stellar Community Fund #28", status: "pending" }, + }, + { + type: "grant_saved", + title: "Saved grant opportunity", + description: "Saved Soroban Developer Grant to watchlist", + offsetMinutes: 120, + metadata: { grantId: "grant-soroban-dev", grantName: "Soroban Developer Grant" }, + }, + { + type: "account_updated", + title: "Account profile updated", + description: "Updated bio and primary developer skills", + offsetMinutes: 1440, + metadata: { updatedFields: ["bio", "skills"] }, + }, + { + type: "submission_created", + title: "Submitted bounty task", + description: "Submitted work for Paymesh Smart Contract audit", + offsetMinutes: 2880, + metadata: { taskId: "task-1", taskTitle: "Smart Contract Audit" }, + }, + ]; + + for (const sample of sampleActivities) { + const time = new Date(now - sample.offsetMinutes * 60 * 1000); + recordActivity( + { + userId, + type: sample.type, + title: sample.title, + description: sample.description, + metadata: sample.metadata, + }, + time, + ); + } +} + +export function resetActivityStore() { + activities.clear(); + nextActivityId = 1; +} diff --git a/frontend/src/test/regression.test.ts b/frontend/src/test/regression.test.ts index ae9dd48..1678bdf 100644 --- a/frontend/src/test/regression.test.ts +++ b/frontend/src/test/regression.test.ts @@ -123,6 +123,28 @@ describe("Regression: Issue #84 - Lint Check Enforcement", () => { }); }); +// ============================================================================ +// Issue: User Activity Timeline Implementation +// ============================================================================ +// Feature: Create a timeline that displays relevant user activities, such as +// saved grants, submitted applications, and account updates. +// +// Acceptance Criteria: +// - Activities are displayed chronologically (newest first). +// - Relevant activity types are supported (saved grants, applications, account updates, etc.). +// - Users only see their own activity (strict user isolation). +// ============================================================================ + +describe("Regression: User Activity Timeline", () => { + it("should have activity-store test suite in place", () => { + expect(() => import("../lib/activity-store.test")).not.toThrow(); + }); + + it("should have activities-api test suite in place", () => { + expect(() => import("../lib/activities-api.test")).not.toThrow(); + }); +}); + // ============================================================================ // Template for Future Regression Tests // ============================================================================ diff --git a/frontend/src/types/activity.ts b/frontend/src/types/activity.ts new file mode 100644 index 0000000..9574f8c --- /dev/null +++ b/frontend/src/types/activity.ts @@ -0,0 +1,47 @@ +export type ActivityType = + | "grant_saved" + | "grant_unsaved" + | "application_submitted" + | "application_status_updated" + | "account_updated" + | "bounty_created" + | "submission_created" + | "submission_reviewed" + | "comment_posted" + | "profile_updated"; + +export interface ActivityRecord { + id: string; + /** Wallet address or user ID owning this activity */ + userId: string; + type: ActivityType; + title: string; + description: string; + timestamp: string; // ISO string + metadata?: { + grantId?: string; + grantName?: string; + applicationId?: string; + status?: string; + taskId?: string; + taskTitle?: string; + submissionId?: string; + updatedFields?: string[]; + [key: string]: unknown; + }; +} + +export interface CreateActivityInput { + userId: string; + type: ActivityType; + title: string; + description: string; + metadata?: ActivityRecord["metadata"]; +} + +export interface ListActivitiesQuery { + userId: string; + type?: ActivityType; + limit?: number; + offset?: number; +}