Skip to content
Open
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
16 changes: 13 additions & 3 deletions app/components/StreamRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StreamProgress } from "./StreamProgress";
import { MiniBurnDown } from "./MiniBurnDown";
import { ErrorToast } from "./ErrorToast";
import { fetchWithIdempotency } from "../../lib/apiClient";
import { formatStreamingRate } from "../../lib/format-rate";
import { isStreamPayError, formatErrorForDisplay } from "../lib/errors";
import type { StreamPayError } from "../lib/errors";

Expand All @@ -16,6 +17,8 @@ export type StreamRowData = {
recipient: string;
schedule: string;
status: StreamStatus;
/** Asset symbol used when API responses keep the rate amount separate from token metadata. */
token?: string;
/** Amount already accrued (display units). Used by StreamProgress. */
accruedAmount?: number;
/** Total stream amount (display units). Used by StreamProgress. */
Expand All @@ -37,6 +40,10 @@ export function StreamRow({ stream }: StreamRowProps) {
const [errorMsg, setErrorMsg] = useState("");
// Local notification state for polite screen reader announcements (#219)
const [srAnnouncement, setSrAnnouncement] = useState("");
const formattedRate = formatStreamingRate(stream.rate, {
asset: stream.token,
sourceInterval: stream.schedule,
});

// Ref hook to preserve active keyboard focus target parameters across button re-renders
const actionButtonRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -120,8 +127,11 @@ export function StreamRow({ stream }: StreamRowProps) {
<div className="stream-row__meta">
<div>
<dt>Rate</dt>
<dd className={stream.status === "active" ? "stream-row__accrued--animated" : ""}>
{stream.rate}
<dd
className={stream.status === "active" ? "stream-row__accrued--animated" : ""}
title={formattedRate === stream.rate ? undefined : stream.rate}
>
{formattedRate}
</dd>
</div>
<div>
Expand Down Expand Up @@ -191,4 +201,4 @@ export function StreamRow({ stream }: StreamRowProps) {
)}
</article>
);
}
}
30 changes: 30 additions & 0 deletions app/streams/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@ describe("StreamsPageContent", () => {
expect(screen.getByRole("button", { name: /export history/i })).toBeInTheDocument();
});

it("formats raw stream rates into human-readable display values", () => {
render(
<StreamsPageContent
state="populated"
streams={[
{
id: "stream-per-second",
nextAction: "Pause",
rate: "0.0001388889 XLM / second",
recipient: "Hourly Recipient",
schedule: "second",
status: "active",
},
{
id: "stream-raw-month",
nextAction: "Start",
rate: "50",
recipient: "Monthly Recipient",
schedule: "month",
status: "draft",
token: "USDC",
},
]}
/>,
);

expect(screen.getByText("0.50 XLM / hour")).toHaveClass("stream-row__accrued--animated");
expect(screen.getByText("50 USDC / month")).not.toHaveClass("stream-row__accrued--animated");
});

it("renders calendar-month edge case schedule messaging", () => {
render(
<StreamsPageContent
Expand Down
57 changes: 57 additions & 0 deletions lib/format-rate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { formatStreamingRate } from "./format-rate";

describe("formatStreamingRate", () => {
it("formats per-second rates into the first readable human unit", () => {
expect(
formatStreamingRate("0.0001388889 XLM / second", { locale: "en-US" }),
).toBe("0.50 XLM / hour");
});

it("supports plural and abbreviated second aliases", () => {
expect(formatStreamingRate("0.0001388889 USDC / seconds", { locale: "en-US" })).toBe(
"0.50 USDC / hour",
);
expect(formatStreamingRate("0.0001388889 USDC/sec", { locale: "en-US" })).toBe(
"0.50 USDC / hour",
);
});

it("uses day or week when hourly amounts are too small to scan", () => {
expect(formatStreamingRate("0.0000003 XLM / second", { locale: "en-US" })).toBe(
"0.02592 XLM / day",
);
expect(formatStreamingRate("0.000000001 XLM / second", { locale: "en-US" })).toBe(
"0.0006048 XLM / week",
);
});

it("formats bare numeric values with a source interval and fallback asset", () => {
expect(
formatStreamingRate("50", {
asset: "USDC",
locale: "en-US",
sourceInterval: "month",
}),
).toBe("50 USDC / month");
});

it("treats bare numeric values without a source interval as per-second rates", () => {
expect(formatStreamingRate("0.0001388889", { locale: "en-US" })).toBe(
"0.50 XLM / hour",
);
});

it("normalizes already formatted rates without changing their cadence", () => {
expect(formatStreamingRate("120 XLM / month", { locale: "en-US" })).toBe(
"120 XLM / month",
);
expect(formatStreamingRate("32 XLM/weeks", { locale: "en-US" })).toBe(
"32 XLM / week",
);
});

it("falls back to the original value for invalid rates", () => {
expect(formatStreamingRate("not-a-rate")).toBe("not-a-rate");
expect(formatStreamingRate("-1 XLM / second")).toBe("-1 XLM / second");
});
});
203 changes: 203 additions & 0 deletions lib/format-rate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
export type RateInterval =
| "second"
| "minute"
| "hour"
| "day"
| "week"
| "month"
| "year";

export type FormatStreamingRateOptions = {
/** Asset symbol used when the raw rate has no explicit asset. */
asset?: string;
/** Source cadence used when the raw rate has no explicit "/ interval" suffix. */
sourceInterval?: string;
/** Locale passed through to Intl.NumberFormat. */
locale?: Intl.LocalesArgument;
};

type ParsedRate = {
amount: number;
asset?: string;
interval?: RateInterval;
};

const DEFAULT_ASSET = "XLM";
const SIGNIFICANT_DIGITS = 4;
const MIN_SUBUNIT_FRACTION_DIGITS = 2;

const DECIMAL_PATTERN = /^[+]?(?:\d+(?:\.\d+)?|\.\d+)$/;
const RATE_PATTERN =
/^\s*([+]?(?:\d+(?:\.\d+)?|\.\d+))\s*([A-Za-z][A-Za-z0-9_-]*)?\s*(?:\/|per)\s*([A-Za-z]+)\s*$/i;

const INTERVAL_SECONDS: Record<RateInterval, number> = {
day: 86_400,
hour: 3_600,
minute: 60,
month: 2_592_000,
second: 1,
week: 604_800,
year: 31_536_000,
};

const HUMAN_INTERVALS: Array<{ interval: Extract<RateInterval, "hour" | "day" | "week">; seconds: number }> = [
{ interval: "hour", seconds: INTERVAL_SECONDS.hour },
{ interval: "day", seconds: INTERVAL_SECONDS.day },
{ interval: "week", seconds: INTERVAL_SECONDS.week },
];

const INTERVAL_ALIASES: Record<string, RateInterval> = {
d: "day",
day: "day",
days: "day",
h: "hour",
hr: "hour",
hrs: "hour",
hour: "hour",
hours: "hour",
m: "minute",
min: "minute",
mins: "minute",
minute: "minute",
minutes: "minute",
mo: "month",
month: "month",
months: "month",
s: "second",
sec: "second",
secs: "second",
second: "second",
seconds: "second",
w: "week",
wk: "week",
wks: "week",
week: "week",
weeks: "week",
y: "year",
yr: "year",
yrs: "year",
year: "year",
years: "year",
};

function normalizeInterval(input?: string): RateInterval | undefined {
if (!input) return undefined;
const normalized = input.trim().toLowerCase();
return INTERVAL_ALIASES[normalized];
}

function parseRate(rawRate: string): ParsedRate | null {
const trimmed = rawRate.trim();
const unitMatch = RATE_PATTERN.exec(trimmed);

if (unitMatch) {
const amount = Number(unitMatch[1]);
if (!Number.isFinite(amount)) return null;

return {
amount,
asset: unitMatch[2],
interval: normalizeInterval(unitMatch[3]),
};
}

if (!DECIMAL_PATTERN.test(trimmed)) {
return null;
}

const amount = Number(trimmed);
return Number.isFinite(amount) ? { amount } : null;
}

function roundToSignificantDigits(value: number): number {
if (value === 0) return 0;

const magnitude = Math.floor(Math.log10(Math.abs(value)));
const factor = 10 ** (SIGNIFICANT_DIGITS - magnitude - 1);
return Math.round((value + Number.EPSILON) * factor) / factor;
}

function significantFractionDigits(value: number): number {
if (value === 0) return 0;

const magnitude = Math.floor(Math.log10(Math.abs(value)));
return Math.max(0, SIGNIFICANT_DIGITS - magnitude - 1);
}

function displayFractionDigits(value: number): number {
const rounded = roundToSignificantDigits(value);
const maximumFractionDigits = Math.min(
8,
Math.max(
significantFractionDigits(rounded),
Math.abs(rounded) > 0 && Math.abs(rounded) < 1 ? MIN_SUBUNIT_FRACTION_DIGITS : 0,
),
);

if (maximumFractionDigits === 0) {
return 0;
}

const minimumFractionDigits =
Math.abs(rounded) > 0 && Math.abs(rounded) < 1 ? MIN_SUBUNIT_FRACTION_DIGITS : 0;
const fixed = rounded.toFixed(maximumFractionDigits);
const fraction = fixed.split(".")[1] ?? "";
const trimmed = fraction.replace(/0+$/, "");

return Math.max(minimumFractionDigits, trimmed.length);
}

function formatAmount(value: number, locale?: Intl.LocalesArgument): string {
const rounded = roundToSignificantDigits(value);
const fractionDigits = displayFractionDigits(rounded);

return new Intl.NumberFormat(locale, {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
useGrouping: false,
}).format(rounded);
}

function chooseHumanInterval(perSecond: number): (typeof HUMAN_INTERVALS)[number] {
const readableInterval = HUMAN_INTERVALS.find(({ seconds }) => Math.abs(perSecond * seconds) >= 0.01);
return readableInterval ?? HUMAN_INTERVALS[HUMAN_INTERVALS.length - 1];
}

function formatRateParts(amount: number, asset: string, interval: RateInterval, locale?: Intl.LocalesArgument): string {
return `${formatAmount(amount, locale)} ${asset} / ${interval}`;
}

/**
* Formats a stream rate for compact display in stream rows.
*
* The API can expose rates either as already formatted strings
* ("120 XLM / month") or as raw decimals plus a separate cadence. Raw
* second/minute cadences are converted into the first readable human unit
* among hour, day, and week. Other cadences are normalized without changing
* their meaning.
*/
export function formatStreamingRate(
rawRate: string,
options: FormatStreamingRateOptions = {},
): string {
const parsed = parseRate(rawRate);
if (!parsed) {
return rawRate;
}

const asset = parsed.asset ?? options.asset?.trim() ?? DEFAULT_ASSET;
const sourceInterval = parsed.interval ?? normalizeInterval(options.sourceInterval) ?? "second";

if (sourceInterval === "second" || sourceInterval === "minute") {
const perSecond = parsed.amount / INTERVAL_SECONDS[sourceInterval];
const displayInterval = chooseHumanInterval(perSecond);
return formatRateParts(
perSecond * displayInterval.seconds,
asset,
displayInterval.interval,
options.locale,
);
}

return formatRateParts(parsed.amount, asset, sourceInterval, options.locale);
}