From 35c97af0ff3cdd10f75722544186654f8af653fa Mon Sep 17 00:00:00 2001 From: mondaymose Date: Thu, 24 Sep 2026 18:56:10 +0100 Subject: [PATCH] feat(history): implement invoice history ring buffer event log with type filters and compact timeline --- src/__tests__/HistoryLog.test.tsx | 143 ++++++++++ src/app/invoice/[id]/page.tsx | 6 +- src/components/invoice/HistoryLog.tsx | 375 ++++++++++++++++++++++++++ 3 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/HistoryLog.test.tsx create mode 100644 src/components/invoice/HistoryLog.tsx diff --git a/src/__tests__/HistoryLog.test.tsx b/src/__tests__/HistoryLog.test.tsx new file mode 100644 index 0000000..146670c --- /dev/null +++ b/src/__tests__/HistoryLog.test.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import HistoryLog, { + filterHistoryEntries, + formatRelativeTime, + type HistoryEntry, +} from "@/components/invoice/HistoryLog"; + +describe("HistoryLog & Filter Logic", () => { + const mockEntries: HistoryEntry[] = [ + { + id: "1", + eventType: "payment", + actor: "GBZXN7PIRZGNMHGA728J352Q367AZ2Q2V55HQ2BEXD7J7", + amount: "150.00", + currency: "USDC", + details: "Contribution received", + timestamp: 1700000000000, + }, + { + id: "2", + eventType: "release", + actor: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7", + details: "Funds released to recipients", + timestamp: 1700003600000, + }, + { + id: "3", + eventType: "note", + actor: "GBZXN7PIRZGNMHGA728J352Q367AZ2Q2V55HQ2BEXD7J7", + details: "Memo attached to payment", + timestamp: 1700007200000, + }, + { + id: "4", + eventType: "status_change", + actor: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7", + details: "Status updated from Pending to Fully Funded", + timestamp: 1700010800000, + }, + ]; + + describe("filterHistoryEntries pure function", () => { + it("returns all entries when filter is 'all'", () => { + const filtered = filterHistoryEntries(mockEntries, "all"); + expect(filtered).toHaveLength(4); + }); + + it("filters only payments when filter is 'payments'", () => { + const filtered = filterHistoryEntries(mockEntries, "payments"); + expect(filtered).toHaveLength(1); + expect(filtered[0].eventType).toBe("payment"); + }); + + it("filters only releases when filter is 'releases'", () => { + const filtered = filterHistoryEntries(mockEntries, "releases"); + expect(filtered).toHaveLength(1); + expect(filtered[0].eventType).toBe("release"); + }); + + it("filters only notes when filter is 'notes'", () => { + const filtered = filterHistoryEntries(mockEntries, "notes"); + expect(filtered).toHaveLength(1); + expect(filtered[0].eventType).toBe("note"); + }); + + it("filters only status changes when filter is 'status_changes'", () => { + const filtered = filterHistoryEntries(mockEntries, "status_changes"); + expect(filtered).toHaveLength(1); + expect(filtered[0].eventType).toBe("status_change"); + }); + }); + + describe("formatRelativeTime helper", () => { + const fixedNow = 1700000000000; + + it("returns 'just now' for events within 60 seconds", () => { + expect(formatRelativeTime(fixedNow - 30 * 1000, fixedNow)).toBe("just now"); + }); + + it("returns minutes ago for events within the hour", () => { + expect(formatRelativeTime(fixedNow - 15 * 60 * 1000, fixedNow)).toBe("15m ago"); + }); + + it("returns hours ago for events within 24 hours", () => { + expect(formatRelativeTime(fixedNow - 4 * 3600 * 1000, fixedNow)).toBe("4h ago"); + }); + + it("returns days ago for older events", () => { + expect(formatRelativeTime(fixedNow - 3 * 86400 * 1000, fixedNow)).toBe("3d ago"); + }); + }); + + describe("HistoryLog component", () => { + it("renders all entries initially with All filter active", () => { + render(); + + expect(screen.getByText("On-Chain History Log")).toBeInTheDocument(); + expect(screen.getByText("Payment")).toBeInTheDocument(); + expect(screen.getByText("Release")).toBeInTheDocument(); + expect(screen.getByText("Note")).toBeInTheDocument(); + expect(screen.getByText("Status Change")).toBeInTheDocument(); + }); + + it("filters list when a filter chip is clicked", () => { + render(); + + const paymentsChip = screen.getByRole("tab", { name: "Payments" }); + fireEvent.click(paymentsChip); + + expect(screen.getByText("Payment")).toBeInTheDocument(); + expect(screen.queryByText("Release")).not.toBeInTheDocument(); + expect(screen.queryByText("Note")).not.toBeInTheDocument(); + }); + + it("shows empty state when filter matches no entries", () => { + const entriesWithoutNotes: HistoryEntry[] = [mockEntries[0]]; + render(); + + const notesChip = screen.getByRole("tab", { name: "Notes" }); + fireEvent.click(notesChip); + + expect(screen.getByText("No history yet")).toBeInTheDocument(); + }); + + it("toggles compact timeline view", () => { + render(); + + const toggleButton = screen.getByRole("button", { name: /compact timeline/i }); + fireEvent.click(toggleButton); + + expect(screen.getByRole("button", { name: /detailed view/i })).toBeInTheDocument(); + }); + + it("provides JSON export button", () => { + render(); + + const exportButton = screen.getByRole("button", { name: /export history as json/i }); + expect(exportButton).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 501ed5d..238f3b2 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -72,6 +72,7 @@ import CommentSection from "@/components/CommentSection"; import CommentThread from "@/components/invoice/CommentThread"; import { loadPermissions } from "@/components/CoCreatorPanel"; import InvoiceTimeline from "@/components/InvoiceTimeline"; +import HistoryLog from "@/components/invoice/HistoryLog"; import InvoiceExportButton from "@/components/InvoiceExportButton"; import ReleaseBanner from "@/components/ReleaseBanner"; import { cancelReminder, setReminder } from "@/lib/reminders"; @@ -1045,8 +1046,7 @@ export default function InvoiceDetailPage({ params }: Props) { {/* Activity Timeline */}
-

Activity Timeline

- +
{/* Tabbed detail section: Audit Log / History / Notes / Comments */} @@ -1078,7 +1078,7 @@ export default function InvoiceDetailPage({ params }: Props) { })} {activeDetailsTab === "audit" && } - {activeDetailsTab === "history" && } + {activeDetailsTab === "history" && } {activeDetailsTab === "notes" && publicKey && ( )} diff --git a/src/components/invoice/HistoryLog.tsx b/src/components/invoice/HistoryLog.tsx new file mode 100644 index 0000000..36ef868 --- /dev/null +++ b/src/components/invoice/HistoryLog.tsx @@ -0,0 +1,375 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { splitClient } from "@/lib/stellar"; + +export type HistoryEventType = "payment" | "release" | "note" | "status_change"; + +export type HistoryFilter = "all" | "payments" | "releases" | "notes" | "status_changes"; + +export interface HistoryEntry { + id: string; + eventType: HistoryEventType; + actor: string; + amount?: string | number; + currency?: string; + details?: string; + timestamp: number; // Unix timestamp in milliseconds + txHash?: string; +} + +export interface HistoryLogProps { + invoiceId: string; + initialEntries?: HistoryEntry[]; + className?: string; +} + +function truncateAddress(addr: string, chars = 6): string { + if (!addr || addr.length <= chars * 2) return addr; + return `${addr.slice(0, chars)}…${addr.slice(-chars)}`; +} + +export function formatRelativeTime(timestampMs: number, now = Date.now()): string { + const diffSec = Math.floor((now - timestampMs) / 1000); + if (diffSec < 60) return "just now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return new Date(timestampMs).toLocaleDateString(); +} + +export const EVENT_ICONS: Record = { + payment: "πŸ’³", + release: "πŸš€", + note: "πŸ“", + status_change: "πŸ”„", +}; + +export const EVENT_LABELS: Record = { + payment: "Payment", + release: "Release", + note: "Note", + status_change: "Status Change", +}; + +export const FILTER_CHIPS: { id: HistoryFilter; label: string }[] = [ + { id: "all", label: "All" }, + { id: "payments", label: "Payments" }, + { id: "releases", label: "Releases" }, + { id: "notes", label: "Notes" }, + { id: "status_changes", label: "Status Changes" }, +]; + +export function filterHistoryEntries( + entries: HistoryEntry[], + filter: HistoryFilter +): HistoryEntry[] { + if (filter === "all") return entries; + if (filter === "payments") return entries.filter((e) => e.eventType === "payment"); + if (filter === "releases") return entries.filter((e) => e.eventType === "release"); + if (filter === "notes") return entries.filter((e) => e.eventType === "note"); + if (filter === "status_changes") return entries.filter((e) => e.eventType === "status_change"); + return entries; +} + +export default function HistoryLog({ + invoiceId, + initialEntries, + className = "", +}: HistoryLogProps) { + const [entries, setEntries] = useState(initialEntries || []); + const [filter, setFilter] = useState("all"); + const [isCompact, setIsCompact] = useState(false); + const [visibleCount, setVisibleCount] = useState(10); + const [loading, setLoading] = useState(!initialEntries); + + useEffect(() => { + if (initialEntries) { + setEntries(initialEntries); + return; + } + + async function loadHistory() { + setLoading(true); + try { + const client = splitClient as any; + // Attempt get_history on contract SDK or API + if (typeof client?.get_history === "function") { + const raw = await client.get_history(invoiceId); + if (Array.isArray(raw)) { + setEntries(raw); + return; + } + } + if (typeof client?.getInvoiceHistory === "function") { + const raw = await client.getInvoiceHistory(invoiceId); + if (Array.isArray(raw)) { + setEntries(raw); + return; + } + } + + // Fetch from API route or invoice details + const res = await fetch(`/api/invoices/${invoiceId}/history`); + if (res.ok) { + const json = await res.json(); + if (Array.isArray(json.payments)) { + const mapped: HistoryEntry[] = json.payments.map((p: any, idx: number) => ({ + id: `pmt-${idx}`, + eventType: "payment", + actor: p.payer || "Unknown", + amount: p.amount ? (Number(p.amount) / 10_000_000).toFixed(2) : undefined, + currency: "USDC", + details: "Payment received", + timestamp: p.timestamp ? p.timestamp * 1000 : Date.now() - idx * 3600000, + })); + setEntries(mapped); + return; + } + } + } catch (err) { + console.error("Failed to load invoice history:", err); + } finally { + setLoading(false); + } + } + + loadHistory(); + }, [invoiceId, initialEntries]); + + // Filter entries + const filteredEntries = useMemo(() => { + return filterHistoryEntries(entries, filter); + }, [entries, filter]); + + const visibleEntries = useMemo(() => { + return filteredEntries.slice(0, visibleCount); + }, [filteredEntries, visibleCount]); + + const hasMore = filteredEntries.length > visibleCount; + + const handleExportJSON = () => { + const dataStr = JSON.stringify(filteredEntries, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `invoice-${invoiceId}-history.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* Header and Controls */} +
+
+

+ On-Chain History Log + + {filteredEntries.length} {filteredEntries.length === 1 ? "event" : "events"} + +

+

+ Interactive event buffer recording verifiable on-chain invoice activities. +

+
+ +
+ {/* Compact View Toggle */} + + + {/* JSON Export Button */} + {entries.length > 0 && ( + + )} +
+
+ + {/* Filter Chips */} +
+ {FILTER_CHIPS.map((chip) => { + const isActive = filter === chip.id; + return ( + + ); + })} +
+ + {/* Loading State */} + {loading && ( +
+ Loading history records… +
+ )} + + {/* Empty State */} + {!loading && filteredEntries.length === 0 && ( +
+

No history yet

+

+ {filter !== "all" + ? `No events match the "${FILTER_CHIPS.find((c) => c.id === filter)?.label}" filter.` + : "No on-chain activity has occurred for this invoice yet."} +

+
+ )} + + {/* Event List (Detailed vs Compact) */} + {!loading && filteredEntries.length > 0 && ( +
+ {isCompact ? ( + /* Compact Timeline View */ +
+ {visibleEntries.map((entry) => { + const absTime = new Date(entry.timestamp).toLocaleString(); + const relTime = formatRelativeTime(entry.timestamp); + return ( +
+ {/* Timeline node dot */} + +
+ + {EVENT_ICONS[entry.eventType]} + + + {EVENT_LABELS[entry.eventType]} + + + by {truncateAddress(entry.actor)} + + {entry.amount && ( + + +{entry.amount} {entry.currency || "USDC"} + + )} +
+ + {relTime} + +
+ ); + })} +
+ ) : ( + /* Detailed View */ +
    + {visibleEntries.map((entry) => { + const absTime = new Date(entry.timestamp).toLocaleString(); + const relTime = formatRelativeTime(entry.timestamp); + return ( +
  • +
    +
    + {EVENT_ICONS[entry.eventType]} +
    +
    +
    + + {EVENT_LABELS[entry.eventType]} + + {entry.amount && ( + + +{entry.amount} {entry.currency || "USDC"} + + )} +
    +

    + Actor:{" "} + + {truncateAddress(entry.actor, 8)} + + {entry.details && ` β€’ ${entry.details}`} +

    +
    +
    + +
    + + {relTime} + + {entry.txHash && ( + + Tx β†— + + )} +
    +
  • + ); + })} +
+ )} + + {/* Load More Pagination */} + {hasMore && ( +
+ +
+ )} +
+ )} +
+ ); +}