diff --git a/src/__tests__/RateCardBuilder.test.tsx b/src/__tests__/RateCardBuilder.test.tsx new file mode 100644 index 0000000..8637eee --- /dev/null +++ b/src/__tests__/RateCardBuilder.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import RateCardBuilder from "@/components/RateCardBuilder"; +import RecipientEarningsPanel from "@/components/analytics/RecipientEarningsPanel"; + +describe("RateCardBuilder", () => { + it("updates the total as rates change and adds rows", () => { + render(); + fireEvent.change(screen.getByLabelText("Rate"), { target: { value: "25" } }); + fireEvent.change(screen.getByLabelText("Quantity"), { target: { value: "4" } }); + expect(screen.getByText("Total: 100.00")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Add service")); + expect(screen.getAllByLabelText("Service")).toHaveLength(2); + }); +}); + +describe("RecipientEarningsPanel", () => { + it("filters earnings by asset", () => { + render( + , + ); + expect(screen.getByText("40.00")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Asset"), { target: { value: "USDC" } }); + expect(screen.getAllByText("30.00").length).toBeGreaterThan(0); + }); +}); diff --git a/src/__tests__/creatorAnalytics.test.ts b/src/__tests__/creatorAnalytics.test.ts new file mode 100644 index 0000000..f2bc05f --- /dev/null +++ b/src/__tests__/creatorAnalytics.test.ts @@ -0,0 +1,63 @@ +import { rateCardTotal, toLineItems, type RateCardItem } from "@/lib/rateCard"; +import { summarizeEarnings } from "@/lib/recipientEarnings"; +import { computeImpact } from "@/lib/invoiceImpact"; +import { predictPayment } from "@/lib/paymentPrediction"; + +describe("rateCard", () => { + const items: RateCardItem[] = [ + { id: "1", service: "Design", rate: 50, unit: "hour", quantity: 3 }, + { id: "2", service: "", rate: 10, unit: "item", quantity: 1 }, + ]; + it("totals line items", () => expect(rateCardTotal(items)).toBe(160)); + it("converts valid items to line items", () => { + expect(toLineItems(items)).toEqual([{ description: "Design (3 hours)", amount: 150 }]); + }); +}); + +describe("summarizeEarnings", () => { + it("aggregates by asset and month", () => { + const s = summarizeEarnings([ + { invoiceId: "a", amount: 10, asset: "XLM", paidAt: "2026-01-05T00:00:00Z" }, + { invoiceId: "b", amount: 30, asset: "USDC", paidAt: "2026-02-05T00:00:00Z" }, + { invoiceId: "c", amount: 20, asset: "XLM", paidAt: "2026-01-20T00:00:00Z" }, + ]); + expect(s.total).toBe(60); + expect(s.average).toBe(20); + expect(s.byAsset).toEqual({ XLM: 30, USDC: 30 }); + expect(s.byMonth).toEqual([{ month: "2026-01", total: 30 }, { month: "2026-02", total: 30 }]); + }); + it("handles empty input", () => expect(summarizeEarnings([]).average).toBe(0)); +}); + +describe("computeImpact", () => { + it("measures outcomes", () => { + const m = computeImpact([ + { status: "funded", target: 100, raised: 100, createdAt: "2026-01-01T00:00:00Z", fundedAt: "2026-01-03T00:00:00Z", payers: 4 }, + { status: "expired", target: 100, raised: 50, createdAt: "2026-01-01T00:00:00Z", payers: 2 }, + ]); + expect(m.completionRate).toBe(0.5); + expect(m.fundingRatio).toBe(0.75); + expect(m.avgDaysToFund).toBe(2); + expect(m.avgPayers).toBe(3); + }); + it("returns zeros for no invoices", () => expect(computeImpact([]).avgDaysToFund).toBeNull()); +}); + +describe("predictPayment", () => { + const now = Date.parse("2026-01-11T00:00:00Z"); + it("is likely when on pace for the deadline", () => { + const p = predictPayment({ target: 100, raised: 50, createdAt: "2026-01-01T00:00:00Z", deadline: "2026-01-31T00:00:00Z", now }); + expect(p.label).toBe("likely"); + expect(p.estimatedCompletion).toBe("2026-01-21T00:00:00.000Z"); + }); + it("is unlikely when behind pace", () => { + const p = predictPayment({ target: 100, raised: 5, createdAt: "2026-01-01T00:00:00Z", deadline: "2026-01-13T00:00:00Z", now }); + expect(p.label).toBe("unlikely"); + }); + it("is 0 after the deadline", () => { + expect(predictPayment({ target: 100, raised: 5, createdAt: "2026-01-01T00:00:00Z", deadline: "2026-01-10T00:00:00Z", now }).probability).toBe(0); + }); + it("is 1 when fully funded", () => { + expect(predictPayment({ target: 100, raised: 100, createdAt: "2026-01-01T00:00:00Z", now }).probability).toBe(1); + }); +}); diff --git a/src/app/creator/rate-card/page.tsx b/src/app/creator/rate-card/page.tsx new file mode 100644 index 0000000..f4d1f47 --- /dev/null +++ b/src/app/creator/rate-card/page.tsx @@ -0,0 +1,10 @@ +import RateCardBuilder from "@/components/RateCardBuilder"; + +export default function RateCardPage() { + return ( +
+

Rate card

+ +
+ ); +} diff --git a/src/components/RateCardBuilder.tsx b/src/components/RateCardBuilder.tsx new file mode 100644 index 0000000..955aa81 --- /dev/null +++ b/src/components/RateCardBuilder.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { lineTotal, rateCardTotal, type RateCardItem } from "@/lib/rateCard"; + +const UNITS: RateCardItem["unit"][] = ["hour", "day", "project", "item"]; + +const newItem = (): RateCardItem => ({ id: crypto.randomUUID(), service: "", rate: 0, unit: "hour", quantity: 1 }); + +export default function RateCardBuilder({ onChange }: { onChange?: (items: RateCardItem[]) => void }) { + const [items, setItems] = useState([newItem()]); + + const update = (next: RateCardItem[]) => { + setItems(next); + onChange?.(next); + }; + const patch = (id: string, changes: Partial) => + update(items.map((i) => (i.id === id ? { ...i, ...changes } : i))); + + return ( +
+ {items.map((item) => ( +
+ patch(item.id, { service: e.target.value })} + /> + patch(item.id, { rate: Number(e.target.value) })} + /> + + patch(item.id, { quantity: Number(e.target.value) })} + /> +
+ {lineTotal(item).toFixed(2)} + +
+
+ ))} +
+ + Total: {rateCardTotal(items).toFixed(2)} +
+
+ ); +} diff --git a/src/components/analytics/InvoiceImpactPanel.tsx b/src/components/analytics/InvoiceImpactPanel.tsx new file mode 100644 index 0000000..aa08da5 --- /dev/null +++ b/src/components/analytics/InvoiceImpactPanel.tsx @@ -0,0 +1,28 @@ +import { computeImpact, type ImpactInvoice } from "@/lib/invoiceImpact"; + +const pct = (n: number) => `${Math.round(n * 100)}%`; + +export default function InvoiceImpactPanel({ invoices }: { invoices: ImpactInvoice[] }) { + const m = computeImpact(invoices); + const stats: [string, string][] = [ + ["Invoices", String(m.total)], + ["Completion rate", pct(m.completionRate)], + ["Funding ratio", pct(m.fundingRatio)], + ["Avg. days to fund", m.avgDaysToFund === null ? "—" : m.avgDaysToFund.toFixed(1)], + ["Total raised", m.totalRaised.toFixed(2)], + ["Avg. payers", m.avgPayers.toFixed(1)], + ]; + return ( +
+

Impact

+
+ {stats.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ); +} diff --git a/src/components/analytics/PaymentPredictionBadge.tsx b/src/components/analytics/PaymentPredictionBadge.tsx new file mode 100644 index 0000000..0f88ee9 --- /dev/null +++ b/src/components/analytics/PaymentPredictionBadge.tsx @@ -0,0 +1,20 @@ +import { predictPayment, type PredictionInput } from "@/lib/paymentPrediction"; + +const STYLES = { + likely: "bg-green-100 text-green-800", + possible: "bg-yellow-100 text-yellow-800", + unlikely: "bg-red-100 text-red-800", +}; + +export default function PaymentPredictionBadge(props: PredictionInput) { + const p = predictPayment(props); + const eta = p.estimatedCompletion ? new Date(p.estimatedCompletion).toLocaleDateString() : "unknown"; + return ( + + {Math.round(p.probability * 100)}% {p.label} to be paid + + ); +} diff --git a/src/components/analytics/RecipientEarningsPanel.tsx b/src/components/analytics/RecipientEarningsPanel.tsx new file mode 100644 index 0000000..3d4608f --- /dev/null +++ b/src/components/analytics/RecipientEarningsPanel.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { summarizeEarnings, type EarningRecord } from "@/lib/recipientEarnings"; + +export default function RecipientEarningsPanel({ records }: { records: EarningRecord[] }) { + const assets = useMemo(() => Array.from(new Set(records.map((r) => r.asset))), [records]); + const [asset, setAsset] = useState("all"); + const summary = useMemo( + () => summarizeEarnings(asset === "all" ? records : records.filter((r) => r.asset === asset)), + [records, asset], + ); + const max = Math.max(1, ...summary.byMonth.map((m) => m.total)); + + return ( +
+
+

Earnings

+ +
+
+ {[ + ["Total", summary.total.toFixed(2)], + ["Payments", String(summary.count)], + ["Average", summary.average.toFixed(2)], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
    + {summary.byMonth.map((m) => ( +
  • + {m.month} + + {m.total.toFixed(2)} +
  • + ))} +
+
+ ); +} diff --git a/src/lib/invoiceImpact.ts b/src/lib/invoiceImpact.ts new file mode 100644 index 0000000..819d9b0 --- /dev/null +++ b/src/lib/invoiceImpact.ts @@ -0,0 +1,40 @@ +export interface ImpactInvoice { + status: "pending" | "funded" | "released" | "expired" | "cancelled"; + target: number; + raised: number; + createdAt: string; + fundedAt?: string; + payers: number; +} + +export interface ImpactMetrics { + total: number; + completionRate: number; + fundingRatio: number; + avgDaysToFund: number | null; + totalRaised: number; + avgPayers: number; +} + +const DAY_MS = 86_400_000; + +export function computeImpact(invoices: ImpactInvoice[]): ImpactMetrics { + const total = invoices.length; + if (total === 0) { + return { total: 0, completionRate: 0, fundingRatio: 0, avgDaysToFund: null, totalRaised: 0, avgPayers: 0 }; + } + const completed = invoices.filter((i) => i.status === "funded" || i.status === "released"); + const totalRaised = invoices.reduce((s, i) => s + i.raised, 0); + const totalTarget = invoices.reduce((s, i) => s + i.target, 0); + const durations = completed + .filter((i) => i.fundedAt) + .map((i) => (Date.parse(i.fundedAt!) - Date.parse(i.createdAt)) / DAY_MS); + return { + total, + completionRate: completed.length / total, + fundingRatio: totalTarget > 0 ? totalRaised / totalTarget : 0, + avgDaysToFund: durations.length ? durations.reduce((a, b) => a + b, 0) / durations.length : null, + totalRaised, + avgPayers: invoices.reduce((s, i) => s + i.payers, 0) / total, + }; +} diff --git a/src/lib/paymentPrediction.ts b/src/lib/paymentPrediction.ts new file mode 100644 index 0000000..7807cc9 --- /dev/null +++ b/src/lib/paymentPrediction.ts @@ -0,0 +1,37 @@ +export interface PredictionInput { + target: number; + raised: number; + createdAt: string; + deadline?: string; + now?: number; +} + +export interface PaymentPrediction { + probability: number; + estimatedCompletion: string | null; + label: "likely" | "possible" | "unlikely"; +} + +/** Linear-velocity model: projects the current funding rate forward to the deadline. */ +export function predictPayment(input: PredictionInput): PaymentPrediction { + const now = input.now ?? Date.now(); + const { target, raised } = input; + if (target <= 0 || raised >= target) { + return { probability: 1, estimatedCompletion: new Date(now).toISOString(), label: "likely" }; + } + const elapsed = Math.max(1, now - Date.parse(input.createdAt)); + const velocity = raised / elapsed; + const estimatedCompletion = velocity > 0 ? new Date(now + (target - raised) / velocity).toISOString() : null; + + let probability: number; + if (!input.deadline) { + probability = velocity > 0 ? Math.min(0.95, 0.5 + raised / target / 2) : 0.1; + } else { + const remaining = Date.parse(input.deadline) - now; + if (remaining <= 0) probability = 0; + else probability = Math.min(1, (raised + velocity * remaining) / target); + } + probability = Math.round(probability * 100) / 100; + const label = probability >= 0.75 ? "likely" : probability >= 0.4 ? "possible" : "unlikely"; + return { probability, estimatedCompletion, label }; +} diff --git a/src/lib/rateCard.ts b/src/lib/rateCard.ts new file mode 100644 index 0000000..a2871d0 --- /dev/null +++ b/src/lib/rateCard.ts @@ -0,0 +1,26 @@ +export interface RateCardItem { + id: string; + service: string; + rate: number; + unit: "hour" | "day" | "project" | "item"; + quantity: number; +} + +export function lineTotal(item: RateCardItem): number { + return Math.max(0, item.rate) * Math.max(0, item.quantity); +} + +export function rateCardTotal(items: RateCardItem[]): number { + return items.reduce((sum, item) => sum + lineTotal(item), 0); +} + +export function isValidItem(item: RateCardItem): boolean { + return item.service.trim().length > 0 && item.rate > 0 && item.quantity > 0; +} + +export function toLineItems(items: RateCardItem[]) { + return items.filter(isValidItem).map((item) => ({ + description: `${item.service} (${item.quantity} ${item.unit}${item.quantity === 1 ? "" : "s"})`, + amount: lineTotal(item), + })); +} diff --git a/src/lib/recipientEarnings.ts b/src/lib/recipientEarnings.ts new file mode 100644 index 0000000..b8c739d --- /dev/null +++ b/src/lib/recipientEarnings.ts @@ -0,0 +1,30 @@ +export interface EarningRecord { + invoiceId: string; + amount: number; + asset: string; + paidAt: string; +} + +export interface EarningsSummary { + total: number; + count: number; + average: number; + byAsset: Record; + byMonth: { month: string; total: number }[]; +} + +export function summarizeEarnings(records: EarningRecord[]): EarningsSummary { + const byAsset: Record = {}; + const months = new Map(); + let total = 0; + for (const r of records) { + total += r.amount; + byAsset[r.asset] = (byAsset[r.asset] ?? 0) + r.amount; + const month = r.paidAt.slice(0, 7); + months.set(month, (months.get(month) ?? 0) + r.amount); + } + const byMonth = Array.from(months, ([month, t]) => ({ month, total: t })).sort((a, b) => + a.month.localeCompare(b.month), + ); + return { total, count: records.length, average: records.length ? total / records.length : 0, byAsset, byMonth }; +}