From 57176a53e3574786c34e5dd7409e1ec576a4dfb9 Mon Sep 17 00:00:00 2001 From: MyouzzZ Date: Fri, 3 Jul 2026 07:47:14 +0800 Subject: [PATCH] feat: format stream row rates --- app/components/StreamRow.tsx | 16 ++- app/streams/page.test.tsx | 30 ++++++ lib/format-rate.test.ts | 57 ++++++++++ lib/format-rate.ts | 203 +++++++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 lib/format-rate.test.ts create mode 100644 lib/format-rate.ts diff --git a/app/components/StreamRow.tsx b/app/components/StreamRow.tsx index 7638d42..ec2e6d6 100644 --- a/app/components/StreamRow.tsx +++ b/app/components/StreamRow.tsx @@ -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"; @@ -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. */ @@ -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(null); @@ -120,8 +127,11 @@ export function StreamRow({ stream }: StreamRowProps) {
Rate
-
- {stream.rate} +
+ {formattedRate}
@@ -191,4 +201,4 @@ export function StreamRow({ stream }: StreamRowProps) { )} ); -} \ No newline at end of file +} diff --git a/app/streams/page.test.tsx b/app/streams/page.test.tsx index 7482f7f..446d260 100644 --- a/app/streams/page.test.tsx +++ b/app/streams/page.test.tsx @@ -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( + , + ); + + 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( { + 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"); + }); +}); diff --git a/lib/format-rate.ts b/lib/format-rate.ts new file mode 100644 index 0000000..c81a597 --- /dev/null +++ b/lib/format-rate.ts @@ -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 = { + 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; seconds: number }> = [ + { interval: "hour", seconds: INTERVAL_SECONDS.hour }, + { interval: "day", seconds: INTERVAL_SECONDS.day }, + { interval: "week", seconds: INTERVAL_SECONDS.week }, +]; + +const INTERVAL_ALIASES: Record = { + 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); +}