Skip to content

Commit 4ddff61

Browse files
authored
Merge pull request #695 from Genghis-codes/fix/issues-152-153-155-216
Federation address lookup, QR stream sharing, and analytics charts
2 parents 8216854 + b143f3c commit 4ddff61

11 files changed

Lines changed: 1199 additions & 99 deletions

File tree

‎app/app/analytics/page.tsx‎

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ import { SectionErrorBoundary } from '@/components/error-boundary/section-error-
1717
import { useStreams } from '@/hooks/use-streams'
1818
import { useNetwork } from '@/components/providers/network-provider'
1919
import { getAllTokens } from '@/lib/stellar'
20-
import { formatCompactAmount, SECONDS_PER_DAY } from '@/lib/stream-utils'
21-
import type { StreamData } from '@/types/stream'
20+
import {
21+
formatCompactAmount,
22+
getStreamStatus,
23+
getUnlockedAmount,
24+
SECONDS_PER_DAY,
25+
} from '@/lib/stream-utils'
26+
import { getFederationNameForAddress } from '@/lib/address-book'
27+
import type { StreamData, StreamStatus } from '@/types/stream'
2228

2329
const AnalyticsCharts = dynamic(
2430
() => import('@/components/analytics/charts').then((m) => m.AnalyticsCharts),
@@ -50,9 +56,20 @@ interface AnalyticsSnapshot {
5056
averageDurationDays: number
5157
/** Fix #367 — `decimals` is now included so amounts format correctly for any token. */
5258
tokenShares: Array<{ symbol: string; amount: bigint; count: number; decimals: number }>
53-
series: Array<{ label: string; count: number }>
59+
series: Array<{ label: string; count: number; volume: number }>
5460
/** Fix #367 — `decimals` is now included so amounts format correctly for any token. */
5561
topTokens: Array<{ symbol: string; amount: bigint; count: number; decimals: number }>
62+
/** Issue #152: stream counts grouped by lifecycle status, for the status breakdown chart. */
63+
statusBreakdown: Array<{ status: StreamStatus; count: number }>
64+
/** Issue #152: highest-volume recipients, for the "Top Recipients" table. */
65+
topRecipients: Array<{
66+
address: string
67+
federationName: string | null
68+
count: number
69+
totals: Array<{ symbol: string; amount: bigint; decimals: number }>
70+
}>
71+
/** Issue #152: aggregate unlocked vs. deposited across the filtered streams. */
72+
unlockProgress: { unlocked: bigint; deposited: bigint }
5673
}
5774

5875
const RANGE_OPTIONS = [
@@ -106,18 +123,78 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot
106123
decimals: entry.decimals,
107124
}))
108125

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

115138
const series = Array.from(seriesMap.entries())
116139
.sort(([a], [b]) => a.localeCompare(b))
117-
.map(([label, count]) => ({ label, count }))
140+
.map(([label, entry]) => ({ label, count: entry.count, volume: entry.volume }))
118141

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

144+
// Issue #152: stream status breakdown (scheduled / streaming / completed / cancelled)
145+
const statusOrder: StreamStatus[] = ['streaming', 'scheduled', 'completed', 'cancelled']
146+
const statusCounts = new Map<StreamStatus, number>()
147+
filtered.forEach((stream) => {
148+
const streamStatus = getStreamStatus(stream, now)
149+
statusCounts.set(streamStatus, (statusCounts.get(streamStatus) ?? 0) + 1)
150+
})
151+
const statusBreakdown = statusOrder.map((streamStatus) => ({
152+
status: streamStatus,
153+
count: statusCounts.get(streamStatus) ?? 0,
154+
}))
155+
156+
// Issue #152: top recipients by streamed volume, with Federation names when known.
157+
const recipientGroups = new Map<
158+
string,
159+
{ count: number; totals: Map<string, { amount: bigint; decimals: number }> }
160+
>()
161+
filtered.forEach((stream) => {
162+
const entry = recipientGroups.get(stream.recipient) ?? {
163+
count: 0,
164+
totals: new Map<string, { amount: bigint; decimals: number }>(),
165+
}
166+
entry.count += 1
167+
const tokenTotal = entry.totals.get(stream.token.symbol) ?? {
168+
amount: 0n,
169+
decimals: stream.token.decimals,
170+
}
171+
tokenTotal.amount += stream.depositedAmount
172+
entry.totals.set(stream.token.symbol, tokenTotal)
173+
recipientGroups.set(stream.recipient, entry)
174+
})
175+
const topRecipients = Array.from(recipientGroups.entries())
176+
.map(([address, entry]) => ({
177+
address,
178+
federationName: getFederationNameForAddress(address),
179+
count: entry.count,
180+
totals: Array.from(entry.totals.entries()).map(([symbol, t]) => ({
181+
symbol,
182+
amount: t.amount,
183+
decimals: t.decimals,
184+
})),
185+
}))
186+
.sort((a, b) => b.count - a.count)
187+
.slice(0, 5)
188+
189+
// Issue #152: aggregate unlock progress across all filtered streams.
190+
const unlockProgress = filtered.reduce(
191+
(acc, stream) => ({
192+
unlocked: acc.unlocked + getUnlockedAmount(stream, now),
193+
deposited: acc.deposited + stream.depositedAmount,
194+
}),
195+
{ unlocked: 0n, deposited: 0n },
196+
)
197+
121198
return {
122199
totalVolume,
123200
activeCount,
@@ -126,6 +203,9 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot
126203
tokenShares,
127204
series,
128205
topTokens,
206+
statusBreakdown,
207+
topRecipients,
208+
unlockProgress,
129209
}
130210
}
131211

@@ -226,6 +306,9 @@ export default function AnalyticsPage() {
226306
topTokens={snapshot.topTokens}
227307
tokenShares={snapshot.tokenShares}
228308
totalVolume={snapshot.totalVolume}
309+
statusBreakdown={snapshot.statusBreakdown}
310+
topRecipients={snapshot.topRecipients}
311+
unlockProgress={snapshot.unlockProgress}
229312
/>
230313
</SectionErrorBoundary>
231314

‎app/app/create/create-form.tsx‎

Lines changed: 126 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Loader2,
1111
Copy,
1212
Clock,
13+
CheckCircle2,
1314
} from "lucide-react";
1415
import Link from "next/link";
1516
import { toast } from "sonner";
@@ -37,6 +38,7 @@ import {
3738
getAddressBookEntries,
3839
touchAddressBookEntry,
3940
} from "@/lib/address-book";
41+
import { isFederationAddress, resolveFederationAddress } from "@/lib/federation";
4042
import {
4143
buildNextRunAt,
4244
saveRecurringRule,
@@ -197,6 +199,71 @@ export function CreateForm() {
197199
};
198200
});
199201

202+
// Issue #155: Federation address (name*domain.com) support for the
203+
// recipient field. `recipientInput` is exactly what the user typed
204+
// (either a raw G-address or a Federation address); `form.recipient`
205+
// always holds the resolved G-address actually used for the transaction.
206+
const [recipientInput, setRecipientInput] = useState(form.recipient);
207+
const [federationStatus, setFederationStatus] = useState<
208+
"idle" | "loading" | "resolved" | "error"
209+
>("idle");
210+
const [federationError, setFederationError] = useState<string | null>(null);
211+
const [federationResolved, setFederationResolved] = useState<{
212+
federationAddress: string;
213+
accountId: string;
214+
} | null>(null);
215+
216+
useEffect(() => {
217+
const raw = recipientInput.trim();
218+
219+
if (!raw) {
220+
setFederationStatus("idle");
221+
setFederationError(null);
222+
setFederationResolved(null);
223+
set("recipient", "");
224+
return;
225+
}
226+
227+
if (!isFederationAddress(raw)) {
228+
setFederationStatus("idle");
229+
setFederationError(null);
230+
setFederationResolved(null);
231+
set("recipient", raw);
232+
return;
233+
}
234+
235+
setFederationStatus("loading");
236+
setFederationError(null);
237+
let cancelled = false;
238+
const timer = setTimeout(() => {
239+
resolveFederationAddress(raw)
240+
.then((result) => {
241+
if (cancelled) return;
242+
setFederationResolved({
243+
federationAddress: result.federationAddress,
244+
accountId: result.accountId,
245+
});
246+
setFederationStatus("resolved");
247+
set("recipient", result.accountId);
248+
})
249+
.catch((err) => {
250+
if (cancelled) return;
251+
setFederationResolved(null);
252+
setFederationStatus("error");
253+
setFederationError(
254+
err instanceof Error ? err.message : "Federation lookup failed",
255+
);
256+
set("recipient", "");
257+
});
258+
}, 500);
259+
260+
return () => {
261+
cancelled = true;
262+
clearTimeout(timer);
263+
};
264+
// eslint-disable-next-line react-hooks/exhaustive-deps
265+
}, [recipientInput]);
266+
200267
const [errors, setErrors] = useState<
201268
Partial<Record<keyof FormState, string>>
202269
>({});
@@ -301,6 +368,7 @@ export function CreateForm() {
301368
form,
302369
(draft) => {
303370
setForm(draft);
371+
setRecipientInput(draft.recipient);
304372
},
305373
true,
306374
);
@@ -355,12 +423,19 @@ export function CreateForm() {
355423
function validate(): boolean {
356424
const newErrors: Partial<Record<keyof FormState, string>> = {};
357425

358-
// Issue #28: use StrKey for proper Stellar address validation
359-
if (
426+
// Issue #155: block submission while a Federation address is still resolving
427+
if (federationStatus === "loading") {
428+
newErrors.recipient = "Still resolving the Federation address…";
429+
} else if (federationStatus === "error") {
430+
newErrors.recipient = federationError ?? "Could not resolve Federation address";
431+
} else if (
432+
// Issue #28: use StrKey for proper Stellar address validation
360433
!form.recipient.trim() ||
361434
!StrKey.isValidEd25519PublicKey(form.recipient.trim())
362435
) {
363-
newErrors.recipient = "Invalid Stellar address format";
436+
newErrors.recipient = isFederationAddress(recipientInput.trim())
437+
? "Federation address did not resolve to a valid Stellar account"
438+
: "Invalid Stellar address format";
364439
}
365440
// Issue #103: require warning acknowledgment for unfunded accounts
366441
if (
@@ -808,15 +883,40 @@ export function CreateForm() {
808883
Recipient
809884
</h2>
810885
<div className="space-y-1.5">
811-
<Label htmlFor="recipient">Stellar address</Label>
812-
<Input
813-
id="recipient"
814-
placeholder="GABC…"
815-
value={form.recipient}
816-
onChange={(e) => set("recipient", e.target.value)}
817-
aria-invalid={!!errors.recipient}
818-
className="font-mono text-xs"
819-
/>
886+
<Label htmlFor="recipient">Stellar address or Federation name</Label>
887+
<div className="relative">
888+
<Input
889+
id="recipient"
890+
placeholder="GABC… or alice*domain.com"
891+
value={recipientInput}
892+
onChange={(e) => setRecipientInput(e.target.value)}
893+
aria-invalid={!!errors.recipient}
894+
className="font-mono text-xs pr-8"
895+
/>
896+
{federationStatus === "loading" && (
897+
<Loader2 className="absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
898+
)}
899+
{federationStatus === "resolved" && (
900+
<CheckCircle2 className="absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-emerald-500" />
901+
)}
902+
</div>
903+
{/* Issue #155: Federation address (name*domain.com) resolution */}
904+
{federationStatus === "resolved" && federationResolved && (
905+
<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">
906+
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
907+
<div>
908+
<p className="font-medium">
909+
{federationResolved.federationAddress} resolved
910+
</p>
911+
<p className="font-mono text-xs opacity-80 break-all">
912+
{federationResolved.accountId}
913+
</p>
914+
</div>
915+
</div>
916+
)}
917+
{federationStatus === "error" && federationError && (
918+
<p className="text-xs text-destructive">{federationError}</p>
919+
)}
820920
<div className="flex flex-wrap gap-2">
821921
<Button
822922
type="button"
@@ -827,8 +927,15 @@ export function CreateForm() {
827927
if (!trimmed || !StrKey.isValidEd25519PublicKey(trimmed))
828928
return;
829929
addAddressBookEntry({
830-
label: "Saved recipient",
930+
label:
931+
federationResolved?.accountId === trimmed
932+
? federationResolved.federationAddress
933+
: "Saved recipient",
831934
address: trimmed,
935+
federationAddress:
936+
federationResolved?.accountId === trimmed
937+
? federationResolved.federationAddress
938+
: undefined,
832939
});
833940
setAddressBookEntries(getAddressBookEntries());
834941
toast.success("Recipient saved");
@@ -851,11 +958,15 @@ export function CreateForm() {
851958
<button
852959
key={entry.id}
853960
type="button"
854-
onClick={() => set("recipient", entry.address)}
961+
onClick={() => {
962+
setRecipientInput(entry.address);
963+
set("recipient", entry.address);
964+
}}
855965
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"
966+
title={entry.address}
856967
>
857968
<span className="font-medium text-foreground">
858-
{entry.label}
969+
{entry.federationAddress ?? entry.label}
859970
</span>{" "}
860971
• {entry.address.slice(0, 8)}…
861972
</button>

0 commit comments

Comments
 (0)