Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/__tests__/RateCardBuilder.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RateCardBuilder />);
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(
<RecipientEarningsPanel
records={[
{ invoiceId: "a", amount: 10, asset: "XLM", paidAt: "2026-01-05T00:00:00Z" },
{ invoiceId: "b", amount: 30, asset: "USDC", paidAt: "2026-02-05T00:00:00Z" },
]}
/>,
);
expect(screen.getByText("40.00")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Asset"), { target: { value: "USDC" } });
expect(screen.getAllByText("30.00").length).toBeGreaterThan(0);
});
});
63 changes: 63 additions & 0 deletions src/__tests__/creatorAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions src/app/creator/rate-card/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import RateCardBuilder from "@/components/RateCardBuilder";

export default function RateCardPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-4 text-2xl font-semibold">Rate card</h1>
<RateCardBuilder />
</main>
);
}
80 changes: 80 additions & 0 deletions src/components/RateCardBuilder.tsx
Original file line number Diff line number Diff line change
@@ -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<RateCardItem[]>([newItem()]);

const update = (next: RateCardItem[]) => {
setItems(next);
onChange?.(next);
};
const patch = (id: string, changes: Partial<RateCardItem>) =>
update(items.map((i) => (i.id === id ? { ...i, ...changes } : i)));

return (
<section className="space-y-3" aria-label="Rate card builder">
{items.map((item) => (
<div key={item.id} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto] sm:items-center">
<input
aria-label="Service"
className="col-span-2 rounded border px-2 py-1 sm:col-span-1"
placeholder="Service"
value={item.service}
onChange={(e) => patch(item.id, { service: e.target.value })}
/>
<input
aria-label="Rate"
type="number"
min={0}
className="rounded border px-2 py-1"
value={item.rate}
onChange={(e) => patch(item.id, { rate: Number(e.target.value) })}
/>
<select
aria-label="Unit"
className="rounded border px-2 py-1"
value={item.unit}
onChange={(e) => patch(item.id, { unit: e.target.value as RateCardItem["unit"] })}
>
{UNITS.map((u) => (
<option key={u} value={u}>
per {u}
</option>
))}
</select>
<input
aria-label="Quantity"
type="number"
min={0}
className="rounded border px-2 py-1"
value={item.quantity}
onChange={(e) => patch(item.id, { quantity: Number(e.target.value) })}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-sm tabular-nums">{lineTotal(item).toFixed(2)}</span>
<button
type="button"
aria-label="Remove"
className="text-sm text-red-600"
onClick={() => update(items.filter((i) => i.id !== item.id))}
>
×
</button>
</div>
</div>
))}
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3">
<button type="button" className="rounded border px-3 py-1 text-sm" onClick={() => update([...items, newItem()])}>
Add service
</button>
<strong className="tabular-nums">Total: {rateCardTotal(items).toFixed(2)}</strong>
</div>
</section>
);
}
28 changes: 28 additions & 0 deletions src/components/analytics/InvoiceImpactPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Invoice impact">
<h2 className="mb-3 text-lg font-semibold">Impact</h2>
<dl className="grid grid-cols-2 gap-3 md:grid-cols-3">
{stats.map(([label, value]) => (
<div key={label} className="rounded border p-3">
<dt className="text-xs text-gray-500">{label}</dt>
<dd className="text-xl font-semibold tabular-nums">{value}</dd>
</div>
))}
</dl>
</section>
);
}
20 changes: 20 additions & 0 deletions src/components/analytics/PaymentPredictionBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STYLES[p.label]}`}
title={`Estimated completion: ${eta}`}
>
{Math.round(p.probability * 100)}% {p.label} to be paid
</span>
);
}
51 changes: 51 additions & 0 deletions src/components/analytics/RecipientEarningsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="space-y-4" aria-label="Recipient earnings">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Earnings</h2>
<select aria-label="Asset" className="rounded border px-2 py-1" value={asset} onChange={(e) => setAsset(e.target.value)}>
<option value="all">All assets</option>
{assets.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{[
["Total", summary.total.toFixed(2)],
["Payments", String(summary.count)],
["Average", summary.average.toFixed(2)],
].map(([label, value]) => (
<div key={label} className="rounded border p-3">
<dt className="text-xs text-gray-500">{label}</dt>
<dd className="text-xl font-semibold tabular-nums">{value}</dd>
</div>
))}
</dl>
<ul className="space-y-1">
{summary.byMonth.map((m) => (
<li key={m.month} className="flex items-center gap-2 text-sm">
<span className="w-16 shrink-0">{m.month}</span>
<span className="h-2 rounded bg-indigo-500" style={{ width: `${(m.total / max) * 100}%` }} />
<span className="shrink-0 tabular-nums">{m.total.toFixed(2)}</span>
</li>
))}
</ul>
</section>
);
}
40 changes: 40 additions & 0 deletions src/lib/invoiceImpact.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
37 changes: 37 additions & 0 deletions src/lib/paymentPrediction.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
26 changes: 26 additions & 0 deletions src/lib/rateCard.ts
Original file line number Diff line number Diff line change
@@ -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),
}));
}
Loading
Loading