From 548e938d78c419fdabe262c68852e0b5140fb1a6 Mon Sep 17 00:00:00 2001 From: Farouq Adeshola Fashina Date: Thu, 24 Sep 2026 17:06:45 +0100 Subject: [PATCH] feat: add reusable Contract Activity Timeline Implements chronological contract event timeline with timestamps, transaction hashes, loading/empty states, and pagination. Closes #215 --- __tests__/ActivityTimeline.test.tsx | 129 +++++++ app/dashboard/contracts/[id]/page.tsx | 4 + components/ActivityTimeline.tsx | 505 +++++++++++++++++++++++--- 3 files changed, 589 insertions(+), 49 deletions(-) create mode 100644 __tests__/ActivityTimeline.test.tsx diff --git a/__tests__/ActivityTimeline.test.tsx b/__tests__/ActivityTimeline.test.tsx new file mode 100644 index 0000000..a58addf --- /dev/null +++ b/__tests__/ActivityTimeline.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { + ActivityTimeline, + extractTransactionHash, + formatEventTimestamp, + sortEventsChronologically, + type ContractTimelineEvent, +} from '@/components/ActivityTimeline' + +vi.mock('@/components/ui/badge', () => ({ + Badge: ({ children, ...props }: React.PropsWithChildren>) => ( + {children} + ), +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.PropsWithChildren>) => ( + + ), +})) + +const sampleEvents: ContractTimelineEvent[] = [ + { + id: '2', + actionType: 'escrow_funded', + description: 'Escrow funded with 500 USDC', + createdAt: '2026-09-22T12:00:00.000Z', + actorUsername: 'client1', + metadata: { txHash: 'abcdef0123456789abcdef0123456789abcdef01' }, + }, + { + id: '1', + actionType: 'contract_created', + description: 'Contract created', + createdAt: '2026-09-21T10:00:00.000Z', + actorUsername: 'client1', + metadata: {}, + }, + { + id: '3', + actionType: 'milestone_submitted', + description: 'Milestone 1 submitted for review', + createdAt: '2026-09-23T08:30:00.000Z', + actorUsername: 'freelancer1', + metadata: {}, + }, +] + +describe('ActivityTimeline helpers', () => { + it('sorts events chronologically ascending', () => { + const sorted = sortEventsChronologically(sampleEvents) + expect(sorted.map((e) => e.id)).toEqual(['1', '2', '3']) + }) + + it('extracts transaction hash from metadata aliases', () => { + expect(extractTransactionHash({ txHash: 'abc' })).toBe('abc') + expect(extractTransactionHash({ transactionHash: 'xyz' })).toBe('xyz') + expect(extractTransactionHash({})).toBeNull() + }) + + it('formats timestamps with date and time', () => { + const formatted = formatEventTimestamp('2026-09-21T10:00:00.000Z') + expect(formatted).toMatch(/2026/) + expect(formatted.length).toBeGreaterThan(8) + }) +}) + +describe('ActivityTimeline component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders events in chronological order with timestamps and tx hash', () => { + render() + + expect(screen.getByTestId('contract-activity-timeline')).toBeInTheDocument() + expect(screen.getByText('Contract Activity Timeline')).toBeInTheDocument() + expect(screen.getByText('Contract created')).toBeInTheDocument() + expect(screen.getByText('Escrow funded with 500 USDC')).toBeInTheDocument() + expect(screen.getByText(/tx:/)).toBeInTheDocument() + + const items = screen.getAllByRole('listitem') + expect(items[0]).toHaveTextContent('Contract created') + expect(items[1]).toHaveTextContent('Escrow funded') + expect(items[2]).toHaveTextContent('Milestone 1 submitted') + }) + + it('shows empty state when there are no events', () => { + render() + expect(screen.getByTestId('timeline-empty')).toBeInTheDocument() + expect(screen.getByText(/No activity yet/i)).toBeInTheDocument() + }) + + it('shows loading skeleton', () => { + render() + expect(screen.getByTestId('timeline-loading')).toBeInTheDocument() + }) + + it('fetches activity for a contractId', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + logs: [ + { + id: 'a1', + actionType: 'contract_created', + description: 'Contract opened', + createdAt: '2026-09-20T09:00:00.000Z', + actorUsername: 'alice', + actorWalletAddress: null, + metadata: {}, + }, + ], + pagination: { limit: 20, offset: 0, total: 1, nextOffset: null, hasMore: false }, + }), + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Contract opened')).toBeInTheDocument() + }) + + expect(global.fetch).toHaveBeenCalled() + const calledUrl = String((global.fetch as ReturnType).mock.calls[0][0]) + expect(calledUrl).toContain('contractId=11111111-1111-1111-1111-111111111111') + }) +}) diff --git a/app/dashboard/contracts/[id]/page.tsx b/app/dashboard/contracts/[id]/page.tsx index 7e94854..947c0da 100644 --- a/app/dashboard/contracts/[id]/page.tsx +++ b/app/dashboard/contracts/[id]/page.tsx @@ -12,6 +12,7 @@ import { ContractMilestoneList, type ContractMilestone } from "@/components/dash import { ContractEscrowSummary } from "@/components/dashboard/contract-escrow-summary"; import { EscrowStatusTracker, type EscrowStage } from "@/components/dashboard/escrow-status-tracker"; import { EscrowFundingDialog } from "@/components/dashboard/escrow-funding-dialog"; +import { ActivityTimeline } from "@/components/ActivityTimeline"; interface ProfileInfo { display_name: string | null; @@ -225,6 +226,9 @@ export default function ContractDetailPage() { )} + + {/* Contract Activity Timeline */} + {/* Escrow Funding Dialog */} diff --git a/components/ActivityTimeline.tsx b/components/ActivityTimeline.tsx index 91ec702..6245b14 100644 --- a/components/ActivityTimeline.tsx +++ b/components/ActivityTimeline.tsx @@ -1,56 +1,463 @@ -import React from 'react'; +'use client' -// This data array makes the component "Reusable" as per requirements -const timelineEvents = [ - { id: 1, label: 'Escrow Created', status: 'completed', date: 'Oct 24, 2025' }, - { id: 2, label: 'Milestone Funded', status: 'completed', date: 'Oct 25, 2025' }, - { id: 3, label: 'Payment Released', status: 'current', date: 'In Progress' }, - { id: 4, label: 'Dispute Raised', status: 'upcoming', date: 'Pending' }, -]; +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + AlertCircle, + Banknote, + CheckCircle2, + Clock, + FileText, + Loader2, + RefreshCw, + XCircle, +} from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' -const ActivityTimeline = () => { +export type ContractTimelineEventType = + | 'contract_created' + | 'milestone_created' + | 'milestone_updated' + | 'milestone_submitted' + | 'milestone_approved' + | 'milestone_rejected' + | 'escrow_funded' + | 'payment_released' + | 'escrow_refunded' + | 'dispute_created' + | 'dispute_resolved' + | 'contract_completed' + | 'contract_cancelled' + | string + +export interface ContractTimelineEvent { + id: string + actionType: ContractTimelineEventType + description: string + createdAt: string + actorUsername?: string | null + actorWalletAddress?: string | null + metadata?: Record | null +} + +interface ActivityLogApiRow { + id: string + actionType: string + description: string + createdAt: string + actorUsername: string | null + actorWalletAddress: string | null + metadata: Record +} + +interface ActivityLogPage { + logs: ActivityLogApiRow[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +export interface ActivityTimelineProps { + /** When set, events are fetched from `/api/activity?contractId=…`. */ + contractId?: string + /** Optional controlled events (skips fetch when provided). */ + events?: ContractTimelineEvent[] + /** Page size for API pagination / load-more. */ + pageSize?: number + className?: string + title?: string + /** Force loading UI (useful for tests / parent-driven state). */ + loading?: boolean +} + +const actionTypeConfig: Record< + string, + { label: string; icon: React.ElementType; color: string; bgColor: string; accent: string } +> = { + contract_created: { + label: 'Contract Created', + icon: FileText, + color: 'text-blue-500', + bgColor: 'bg-blue-500/10', + accent: 'border-blue-500/40', + }, + milestone_created: { + label: 'Milestone Created', + icon: FileText, + color: 'text-indigo-500', + bgColor: 'bg-indigo-500/10', + accent: 'border-indigo-500/40', + }, + milestone_updated: { + label: 'Milestone Updated', + icon: FileText, + color: 'text-purple-500', + bgColor: 'bg-purple-500/10', + accent: 'border-purple-500/40', + }, + milestone_submitted: { + label: 'Milestone Submitted', + icon: Clock, + color: 'text-amber-500', + bgColor: 'bg-amber-500/10', + accent: 'border-amber-500/40', + }, + milestone_approved: { + label: 'Milestone Approved', + icon: CheckCircle2, + color: 'text-green-500', + bgColor: 'bg-green-500/10', + accent: 'border-green-500/40', + }, + milestone_rejected: { + label: 'Changes Requested', + icon: XCircle, + color: 'text-red-500', + bgColor: 'bg-red-500/10', + accent: 'border-red-500/40', + }, + escrow_funded: { + label: 'Escrow Funded', + icon: Banknote, + color: 'text-emerald-500', + bgColor: 'bg-emerald-500/10', + accent: 'border-emerald-500/40', + }, + payment_released: { + label: 'Payment Released', + icon: CheckCircle2, + color: 'text-green-600', + bgColor: 'bg-green-600/10', + accent: 'border-green-600/40', + }, + escrow_refunded: { + label: 'Escrow Refunded', + icon: Banknote, + color: 'text-orange-500', + bgColor: 'bg-orange-500/10', + accent: 'border-orange-500/40', + }, + dispute_created: { + label: 'Dispute Raised', + icon: AlertCircle, + color: 'text-red-500', + bgColor: 'bg-red-500/10', + accent: 'border-red-500/40', + }, + dispute_resolved: { + label: 'Dispute Resolved', + icon: CheckCircle2, + color: 'text-teal-500', + bgColor: 'bg-teal-500/10', + accent: 'border-teal-500/40', + }, + contract_completed: { + label: 'Contract Completed', + icon: CheckCircle2, + color: 'text-green-600', + bgColor: 'bg-green-600/10', + accent: 'border-green-600/40', + }, + contract_cancelled: { + label: 'Contract Cancelled', + icon: XCircle, + color: 'text-gray-500', + bgColor: 'bg-gray-500/10', + accent: 'border-gray-500/40', + }, +} + +function getAuthHeaders(): Record { + const token = + typeof window !== 'undefined' ? localStorage.getItem('tc_dev_access_token') : null + return token ? { Authorization: `Bearer ${token}` } : {} +} + +export function extractTransactionHash( + metadata?: Record | null, +): string | null { + if (!metadata) return null + const candidates = [ + metadata.transactionHash, + metadata.txHash, + metadata.tx_hash, + metadata.hash, + metadata.transaction_hash, + ] + for (const value of candidates) { + if (typeof value === 'string' && value.trim().length > 0) return value.trim() + } + return null +} + +export function formatEventTimestamp(dateStr: string): string { + const date = new Date(dateStr) + if (Number.isNaN(date.getTime())) return dateStr + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +/** Ascending chronological order (oldest first) for timeline display. */ +export function sortEventsChronologically( + events: ContractTimelineEvent[], +): ContractTimelineEvent[] { + return [...events].sort((a, b) => { + const ta = new Date(a.createdAt).getTime() + const tb = new Date(b.createdAt).getTime() + if (ta !== tb) return ta - tb + return a.id.localeCompare(b.id) + }) +} + +function TimelineSkeleton() { return ( -
-

Contract Activity Timeline

- -
- {/* The Vertical Line */} -
- -
- {timelineEvents.map((event) => ( -
- - {/* Step Indicator Dot */} -
- - {event.status === 'completed' && ( - ✓ - )} - {event.status === 'current' && ( -
- )} -
- - {/* Event Content */} -
-

- {event.label} -

-

- {event.date} -

-
- -
- ))} +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) +} + +function EmptyTimeline() { + return ( +
+
+
+

No activity yet

+

+ Contract events such as escrow funding, milestone submissions, and payments will appear + here once they occur. +

- ); -}; + ) +} + +export function ActivityTimeline({ + contractId, + events: controlledEvents, + pageSize = 20, + className, + title = 'Contract Activity Timeline', + loading: loadingProp, +}: ActivityTimelineProps) { + const isControlled = controlledEvents !== undefined + const [fetchedEvents, setFetchedEvents] = useState([]) + const [loading, setLoading] = useState(!isControlled) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + const [hasMore, setHasMore] = useState(false) + const [nextOffset, setNextOffset] = useState(null) + + const fetchPage = useCallback( + async (offset: number, append: boolean) => { + if (!contractId || isControlled) return + if (append) setLoadingMore(true) + else { + setLoading(true) + setError(null) + } + try { + const params = new URLSearchParams() + params.set('contractId', contractId) + params.set('limit', String(pageSize)) + params.set('offset', String(offset)) + + const res = await fetch(`/api/activity?${params.toString()}`, { + headers: getAuthHeaders(), + credentials: 'include', + }) + if (!res.ok) { + setError('Failed to load contract activity.') + return + } + const data = (await res.json()) as ActivityLogPage + const mapped: ContractTimelineEvent[] = (data.logs ?? []).map((log) => ({ + id: log.id, + actionType: log.actionType, + description: log.description, + createdAt: log.createdAt, + actorUsername: log.actorUsername, + actorWalletAddress: log.actorWalletAddress, + metadata: log.metadata ?? {}, + })) + setFetchedEvents((prev) => { + if (!append) return mapped + const seen = new Set(prev.map((e) => e.id)) + return [...prev, ...mapped.filter((e) => !seen.has(e.id))] + }) + setHasMore(Boolean(data.pagination?.hasMore)) + setNextOffset(data.pagination?.nextOffset ?? null) + } catch { + setError('Failed to load contract activity.') + } finally { + setLoading(false) + setLoadingMore(false) + } + }, + [contractId, isControlled, pageSize], + ) + + useEffect(() => { + if (isControlled || !contractId) { + setLoading(false) + return + } + fetchPage(0, false) + }, [contractId, isControlled, fetchPage]) + + const sourceEvents = isControlled ? controlledEvents : fetchedEvents + const events = useMemo(() => sortEventsChronologically(sourceEvents), [sourceEvents]) + const showLoading = loadingProp ?? (loading && events.length === 0) + + return ( +
+
+
+

{title}

+

+ Chronological audit trail of contract lifecycle events +

+
+ {!isControlled && contractId && ( + + )} +
+ + {showLoading ? ( + + ) : error ? ( +
+ +

{error}

+ +
+ ) : events.length === 0 ? ( + + ) : ( +
    + {events.map((event, index) => { + const config = actionTypeConfig[event.actionType] + const Icon = config?.icon ?? Clock + const color = config?.color ?? 'text-muted-foreground' + const bgColor = config?.bgColor ?? 'bg-muted/30' + const accent = config?.accent ?? 'border-border' + const txHash = extractTransactionHash(event.metadata) + const actor = + event.actorUsername ?? event.actorWalletAddress ?? 'Unknown actor' + const isLast = index === events.length - 1 + + return ( +
  1. + {!isLast && ( + + )} +
    + +
    +
    +
    + + {config?.label ?? event.actionType.replace(/_/g, ' ')} + + +
    +

    {event.description}

    +
    + {actor} + {txHash && ( + + tx: {txHash.length > 20 ? `${txHash.slice(0, 10)}…${txHash.slice(-8)}` : txHash} + + )} +
    +
    +
  2. + ) + })} +
+ )} + + {!isControlled && hasMore && nextOffset != null && ( +
+ +
+ )} +
+ ) +} -export default ActivityTimeline; \ No newline at end of file +export default ActivityTimeline