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
95 changes: 89 additions & 6 deletions app/app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ import { SectionErrorBoundary } from '@/components/error-boundary/section-error-
import { useStreams } from '@/hooks/use-streams'
import { useNetwork } from '@/components/providers/network-provider'
import { getAllTokens } from '@/lib/stellar'
import { formatCompactAmount, SECONDS_PER_DAY } from '@/lib/stream-utils'
import type { StreamData } from '@/types/stream'
import {
formatCompactAmount,
getStreamStatus,
getUnlockedAmount,
SECONDS_PER_DAY,
} from '@/lib/stream-utils'
import { getFederationNameForAddress } from '@/lib/address-book'
import type { StreamData, StreamStatus } from '@/types/stream'

const AnalyticsCharts = dynamic(
() => import('@/components/analytics/charts').then((m) => m.AnalyticsCharts),
Expand Down Expand Up @@ -50,9 +56,20 @@ interface AnalyticsSnapshot {
averageDurationDays: number
/** Fix #367 — `decimals` is now included so amounts format correctly for any token. */
tokenShares: Array<{ symbol: string; amount: bigint; count: number; decimals: number }>
series: Array<{ label: string; count: number }>
series: Array<{ label: string; count: number; volume: number }>
/** Fix #367 — `decimals` is now included so amounts format correctly for any token. */
topTokens: Array<{ symbol: string; amount: bigint; count: number; decimals: number }>
/** Issue #152: stream counts grouped by lifecycle status, for the status breakdown chart. */
statusBreakdown: Array<{ status: StreamStatus; count: number }>
/** Issue #152: highest-volume recipients, for the "Top Recipients" table. */
topRecipients: Array<{
address: string
federationName: string | null
count: number
totals: Array<{ symbol: string; amount: bigint; decimals: number }>
}>
/** Issue #152: aggregate unlocked vs. deposited across the filtered streams. */
unlockProgress: { unlocked: bigint; deposited: bigint }
}

const RANGE_OPTIONS = [
Expand Down Expand Up @@ -106,18 +123,78 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot
decimals: entry.decimals,
}))

const seriesMap = new Map<string, number>()
const seriesMap = new Map<string, { count: number; volume: number }>()
filtered.forEach((stream) => {
const day = new Date(Number(stream.startTime) * 1000).toISOString().slice(0, 10)
seriesMap.set(day, (seriesMap.get(day) ?? 0) + 1)
const entry = seriesMap.get(day) ?? { count: 0, volume: 0 }
entry.count += 1
// Issue #152: per-day streamed volume for the "volume over time" area chart.
// Mixes tokens as display-unit floats (same simplification `totalVolume`
// already makes with raw bigints) since this is a directional trend chart.
entry.volume += Number(stream.depositedAmount) / 10 ** stream.token.decimals
seriesMap.set(day, entry)
})

const series = Array.from(seriesMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, count]) => ({ label, count }))
.map(([label, entry]) => ({ label, count: entry.count, volume: entry.volume }))

const topTokens = [...tokenShares].sort((a, b) => Number(b.amount - a.amount)).slice(0, 4)

// Issue #152: stream status breakdown (scheduled / streaming / completed / cancelled)
const statusOrder: StreamStatus[] = ['streaming', 'scheduled', 'completed', 'cancelled']
const statusCounts = new Map<StreamStatus, number>()
filtered.forEach((stream) => {
const streamStatus = getStreamStatus(stream, now)
statusCounts.set(streamStatus, (statusCounts.get(streamStatus) ?? 0) + 1)
})
const statusBreakdown = statusOrder.map((streamStatus) => ({
status: streamStatus,
count: statusCounts.get(streamStatus) ?? 0,
}))

// Issue #152: top recipients by streamed volume, with Federation names when known.
const recipientGroups = new Map<
string,
{ count: number; totals: Map<string, { amount: bigint; decimals: number }> }
>()
filtered.forEach((stream) => {
const entry = recipientGroups.get(stream.recipient) ?? {
count: 0,
totals: new Map<string, { amount: bigint; decimals: number }>(),
}
entry.count += 1
const tokenTotal = entry.totals.get(stream.token.symbol) ?? {
amount: 0n,
decimals: stream.token.decimals,
}
tokenTotal.amount += stream.depositedAmount
entry.totals.set(stream.token.symbol, tokenTotal)
recipientGroups.set(stream.recipient, entry)
})
const topRecipients = Array.from(recipientGroups.entries())
.map(([address, entry]) => ({
address,
federationName: getFederationNameForAddress(address),
count: entry.count,
totals: Array.from(entry.totals.entries()).map(([symbol, t]) => ({
symbol,
amount: t.amount,
decimals: t.decimals,
})),
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5)

// Issue #152: aggregate unlock progress across all filtered streams.
const unlockProgress = filtered.reduce(
(acc, stream) => ({
unlocked: acc.unlocked + getUnlockedAmount(stream, now),
deposited: acc.deposited + stream.depositedAmount,
}),
{ unlocked: 0n, deposited: 0n },
)

return {
totalVolume,
activeCount,
Expand All @@ -126,6 +203,9 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot
tokenShares,
series,
topTokens,
statusBreakdown,
topRecipients,
unlockProgress,
}
}

Expand Down Expand Up @@ -226,6 +306,9 @@ export default function AnalyticsPage() {
topTokens={snapshot.topTokens}
tokenShares={snapshot.tokenShares}
totalVolume={snapshot.totalVolume}
statusBreakdown={snapshot.statusBreakdown}
topRecipients={snapshot.topRecipients}
unlockProgress={snapshot.unlockProgress}
/>
</SectionErrorBoundary>

Expand Down
141 changes: 126 additions & 15 deletions app/app/create/create-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Loader2,
Copy,
Clock,
CheckCircle2,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
Expand Down Expand Up @@ -37,6 +38,7 @@ import {
getAddressBookEntries,
touchAddressBookEntry,
} from "@/lib/address-book";
import { isFederationAddress, resolveFederationAddress } from "@/lib/federation";
import {
buildNextRunAt,
saveRecurringRule,
Expand Down Expand Up @@ -197,6 +199,71 @@ export function CreateForm() {
};
});

// Issue #155: Federation address (name*domain.com) support for the
// recipient field. `recipientInput` is exactly what the user typed
// (either a raw G-address or a Federation address); `form.recipient`
// always holds the resolved G-address actually used for the transaction.
const [recipientInput, setRecipientInput] = useState(form.recipient);
const [federationStatus, setFederationStatus] = useState<
"idle" | "loading" | "resolved" | "error"
>("idle");
const [federationError, setFederationError] = useState<string | null>(null);
const [federationResolved, setFederationResolved] = useState<{
federationAddress: string;
accountId: string;
} | null>(null);

useEffect(() => {
const raw = recipientInput.trim();

if (!raw) {
setFederationStatus("idle");
setFederationError(null);
setFederationResolved(null);
set("recipient", "");
return;
}

if (!isFederationAddress(raw)) {
setFederationStatus("idle");
setFederationError(null);
setFederationResolved(null);
set("recipient", raw);
return;
}

setFederationStatus("loading");
setFederationError(null);
let cancelled = false;
const timer = setTimeout(() => {
resolveFederationAddress(raw)
.then((result) => {
if (cancelled) return;
setFederationResolved({
federationAddress: result.federationAddress,
accountId: result.accountId,
});
setFederationStatus("resolved");
set("recipient", result.accountId);
})
.catch((err) => {
if (cancelled) return;
setFederationResolved(null);
setFederationStatus("error");
setFederationError(
err instanceof Error ? err.message : "Federation lookup failed",
);
set("recipient", "");
});
}, 500);

return () => {
cancelled = true;
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipientInput]);

const [errors, setErrors] = useState<
Partial<Record<keyof FormState, string>>
>({});
Expand Down Expand Up @@ -301,6 +368,7 @@ export function CreateForm() {
form,
(draft) => {
setForm(draft);
setRecipientInput(draft.recipient);
},
true,
);
Expand Down Expand Up @@ -355,12 +423,19 @@ export function CreateForm() {
function validate(): boolean {
const newErrors: Partial<Record<keyof FormState, string>> = {};

// Issue #28: use StrKey for proper Stellar address validation
if (
// Issue #155: block submission while a Federation address is still resolving
if (federationStatus === "loading") {
newErrors.recipient = "Still resolving the Federation address…";
} else if (federationStatus === "error") {
newErrors.recipient = federationError ?? "Could not resolve Federation address";
} else if (
// Issue #28: use StrKey for proper Stellar address validation
!form.recipient.trim() ||
!StrKey.isValidEd25519PublicKey(form.recipient.trim())
) {
newErrors.recipient = "Invalid Stellar address format";
newErrors.recipient = isFederationAddress(recipientInput.trim())
? "Federation address did not resolve to a valid Stellar account"
: "Invalid Stellar address format";
}
// Issue #103: require warning acknowledgment for unfunded accounts
if (
Expand Down Expand Up @@ -808,15 +883,40 @@ export function CreateForm() {
Recipient
</h2>
<div className="space-y-1.5">
<Label htmlFor="recipient">Stellar address</Label>
<Input
id="recipient"
placeholder="GABC…"
value={form.recipient}
onChange={(e) => set("recipient", e.target.value)}
aria-invalid={!!errors.recipient}
className="font-mono text-xs"
/>
<Label htmlFor="recipient">Stellar address or Federation name</Label>
<div className="relative">
<Input
id="recipient"
placeholder="GABC… or alice*domain.com"
value={recipientInput}
onChange={(e) => setRecipientInput(e.target.value)}
aria-invalid={!!errors.recipient}
className="font-mono text-xs pr-8"
/>
{federationStatus === "loading" && (
<Loader2 className="absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
{federationStatus === "resolved" && (
<CheckCircle2 className="absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-emerald-500" />
)}
</div>
{/* Issue #155: Federation address (name*domain.com) resolution */}
{federationStatus === "resolved" && federationResolved && (
<div className="flex items-start gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
<div>
<p className="font-medium">
{federationResolved.federationAddress} resolved
</p>
<p className="font-mono text-xs opacity-80 break-all">
{federationResolved.accountId}
</p>
</div>
</div>
)}
{federationStatus === "error" && federationError && (
<p className="text-xs text-destructive">{federationError}</p>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
Expand All @@ -827,8 +927,15 @@ export function CreateForm() {
if (!trimmed || !StrKey.isValidEd25519PublicKey(trimmed))
return;
addAddressBookEntry({
label: "Saved recipient",
label:
federationResolved?.accountId === trimmed
? federationResolved.federationAddress
: "Saved recipient",
address: trimmed,
federationAddress:
federationResolved?.accountId === trimmed
? federationResolved.federationAddress
: undefined,
});
setAddressBookEntries(getAddressBookEntries());
toast.success("Recipient saved");
Expand All @@ -851,11 +958,15 @@ export function CreateForm() {
<button
key={entry.id}
type="button"
onClick={() => set("recipient", entry.address)}
onClick={() => {
setRecipientInput(entry.address);
set("recipient", entry.address);
}}
className="rounded-full border border-border bg-background px-3 py-1 text-left text-xs text-muted-foreground transition-colors hover:border-primary hover:text-primary"
title={entry.address}
>
<span className="font-medium text-foreground">
{entry.label}
{entry.federationAddress ?? entry.label}
</span>{" "}
• {entry.address.slice(0, 8)}…
</button>
Expand Down
Loading
Loading