From 62a4e1915efb4144adc3b3576ddb2cba50c86861 Mon Sep 17 00:00:00 2001 From: Cosmas Ommaike Date: Sun, 27 Sep 2026 22:37:46 +0000 Subject: [PATCH 1/4] docs(recurring): add JSDoc comments to all exported functions (#772) --- lib/recurring.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/lib/recurring.ts b/lib/recurring.ts index 2ba0d88..2fe9f47 100644 --- a/lib/recurring.ts +++ b/lib/recurring.ts @@ -12,6 +12,14 @@ export interface RecurringRule { const STORAGE_KEY = 'flowstar:recurring-streams' +/** + * Reads all recurring stream rules persisted in `localStorage`. + * + * Safe to call in SSR contexts — returns an empty array when `window` is + * unavailable or when the stored value cannot be parsed. + * + * @returns Array of {@link RecurringRule} objects, or `[]` if none are stored. + */ export function getRecurringRules(): RecurringRule[] { if (typeof window === 'undefined') return [] try { @@ -24,6 +32,16 @@ export function getRecurringRules(): RecurringRule[] { } } +/** + * Persists a recurring stream rule to `localStorage`, replacing any existing + * rule for the same `streamId`. + * + * At most 25 rules are kept; the oldest entries are dropped when the list + * exceeds that limit. No-ops in SSR environments. + * + * @param rule - The {@link RecurringRule} to save. An existing rule with the + * same `streamId` is overwritten. + */ export function saveRecurringRule(rule: RecurringRule): void { if (typeof window === 'undefined') return const rules = getRecurringRules().filter((item) => item.streamId !== rule.streamId) @@ -31,12 +49,30 @@ export function saveRecurringRule(rule: RecurringRule): void { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(rules.slice(0, 25))) } +/** + * Removes the recurring rule associated with the given stream ID from + * `localStorage`. No-ops if no matching rule exists or in SSR environments. + * + * @param streamId - The contract stream ID whose recurring rule should be + * deleted. + */ export function removeRecurringRule(streamId: string): void { if (typeof window === 'undefined') return const rules = getRecurringRules().filter((item) => item.streamId !== streamId) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)) } +/** + * Returns all recurring rules whose `nextRunAt` timestamp is in the future, + * sorted in ascending order by `nextRunAt` (soonest renewal first). + * + * Rules whose `nextRunAt` is in the past (i.e. overdue or already processed) + * are excluded. Use {@link getRecurringRules} to retrieve the full unfiltered + * list. + * + * @returns Subset of stored rules that are pending renewal, ordered by + * scheduled time. + */ export function getUpcomingRenewals(): RecurringRule[] { return getRecurringRules() .filter((rule) => rule.nextRunAt > Date.now()) @@ -99,6 +135,20 @@ export function buildNextRunAt(startTime: number, cadence: Exclude): RecurringRule { const preset = { streamId: stream.id, From fc5279fbdff39869ff0e74f72eb417918027a07c Mon Sep 17 00:00:00 2001 From: Cosmas Ommaike Date: Sun, 27 Sep 2026 22:38:14 +0000 Subject: [PATCH 2/4] docs(address-book): add JSDoc comments to all exported functions (#773) --- lib/address-book.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/lib/address-book.ts b/lib/address-book.ts index 9e5beb2..06d59ab 100644 --- a/lib/address-book.ts +++ b/lib/address-book.ts @@ -26,10 +26,32 @@ function writeEntries(entries: AddressBookEntry[]) { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, 50))) } +/** + * Returns all address book entries stored in `localStorage`, sorted by most + * recently used first (`lastUsed` descending). + * + * Safe to call in SSR contexts — returns an empty array when `window` is + * unavailable or when the stored value cannot be parsed. + * + * @returns Array of {@link AddressBookEntry} objects ordered by recency. + */ export function getAddressBookEntries(): AddressBookEntry[] { return readEntries().sort((a, b) => b.lastUsed - a.lastUsed) } +/** + * Adds a new entry to the address book, or replaces an existing entry that + * shares the same `address`. + * + * A unique `id` and a `lastUsed` timestamp (set to `Date.now()`) are + * generated automatically. `label` and `address` are trimmed; an empty or + * missing `federationAddress` is stored as `undefined`. At most 50 entries + * are kept — older duplicates for the same address are removed first. + * + * @param entry - New entry data. `id` and `lastUsed` are generated + * automatically and must not be supplied. + * @returns The fully populated {@link AddressBookEntry} that was persisted. + */ export function addAddressBookEntry(entry: Omit) { const entries = readEntries() const normalized = { @@ -44,6 +66,17 @@ export function addAddressBookEntry(entry: Omit>) { const entries = readEntries() const next = entries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)) @@ -51,11 +84,42 @@ export function updateAddressBookEntry(id: string, patch: Partial entry.id === id) ?? null } +/** + * Permanently removes the address book entry with the given `id` from + * `localStorage`. No-ops silently if no matching entry exists. + * + * @param id - The unique ID of the entry to delete. + */ export function deleteAddressBookEntry(id: string) { const entries = readEntries().filter((entry) => entry.id !== id) writeEntries(entries) } +/** + * Updates `lastUsed` (and optionally `label` / `federationAddress`) for an + * existing entry that matches `address`, or creates a new entry if none is + * found. + * + * This is the preferred way to record a recently used address — callers do + * not need to know whether the address is already in the book. + * + * - If a matching entry exists: `lastUsed` is set to now; `label` and + * `federationAddress` are overwritten only when non-empty values are + * provided. + * - If no match exists and `address` is non-empty: a new entry is created via + * {@link addAddressBookEntry} with `label` defaulting to + * `"Saved recipient"`. + * - If `address` is blank: returns `null` without modifying storage. + * + * @param address - Stellar G-address to touch. + * @param label - Optional display label to update or use for the + * new entry. + * @param federationAddress - Optional Federation address (e.g. + * `alice*stellarx.com`) associated with this + * account. + * @returns The upserted {@link AddressBookEntry}, or `null` if the address + * was blank. + */ export function touchAddressBookEntry(address: string, label?: string, federationAddress?: string) { const entries = readEntries() const existing = entries.find((entry) => entry.address === address) From 5e33b0598f813a16ae857dd501034ae670f990bb Mon Sep 17 00:00:00 2001 From: Cosmas Ommaike Date: Sun, 27 Sep 2026 22:38:31 +0000 Subject: [PATCH 3/4] docs(error-messages): add JSDoc comments to all exported functions (#774) --- lib/error-messages.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/lib/error-messages.ts b/lib/error-messages.ts index 8b02bfa..62818ec 100644 --- a/lib/error-messages.ts +++ b/lib/error-messages.ts @@ -295,6 +295,34 @@ const ERROR_PATTERNS: Array<{ }, ] +/** + * Maps a raw error value thrown during a Soroban transaction (or any other + * operation) to a structured {@link MappedError} with a user-friendly message, + * an actionable suggestion, an {@link ErrorCategory}, and the original error + * string as `details`. + * + * **Matching strategy:** + * The function iterates through `ERROR_PATTERNS` in declaration order. Each + * pattern is either a `RegExp` (tested with `RegExp.test()`) or a plain + * `string` (matched with a case-insensitive `includes()` check). The first + * matching pattern wins. Patterns are ordered so that specific Soroban + * contract error codes (`Error(Contract, #N)`) appear before broader + * catch-all patterns (e.g. "insufficient balance"). + * + * If no pattern matches, a generic `"Transaction failed"` fallback is + * returned with `category: "contract"` and the raw message as `details`. + * + * **Known limitation:** The numeric contract error codes (#1–#20) are mapped + * to the `StreamError` enum in `contracts/streaming/src/lib.rs`. If the + * on-chain enum is extended with new codes, this mapping must be updated + * manually (tracked in issues #231 and #287). + * + * @param raw - The thrown value. Accepts an `Error` instance (uses + * `.message`) or any other type (coerced to a string via + * `String()`). + * @returns A {@link MappedError} with `message`, `suggestion`, `category`, + * and `details` (the original raw message). + */ export function mapError(raw: unknown): MappedError { const rawMessage = raw instanceof Error ? raw.message : String(raw) @@ -316,6 +344,14 @@ export function mapError(raw: unknown): MappedError { } } +/** + * Returns a short, human-readable label for an {@link ErrorCategory} suitable + * for display in UI headings or badges (e.g. `"Input error"`, + * `"Network error"`). + * + * @param category - The error category to label. + * @returns A display-ready string for the given category. + */ export function categoryLabel(category: ErrorCategory): string { switch (category) { case 'user': @@ -329,6 +365,17 @@ export function categoryLabel(category: ErrorCategory): string { } } +/** + * Returns a Tailwind CSS text-colour class pair for the given + * {@link ErrorCategory}, one for light mode and one for dark mode (e.g. + * `"text-amber-600 dark:text-amber-400"`). + * + * Intended to be applied directly to a JSX element's `className` prop so that + * error labels are colour-coded consistently across the UI. + * + * @param category - The error category to colour. + * @returns A Tailwind CSS class string for the matching text colour. + */ export function categoryColor(category: ErrorCategory): string { switch (category) { case 'user': From 501f5ed1368510da4621c7dc7e5c470027d46d70 Mon Sep 17 00:00:00 2001 From: Cosmas Ommaike Date: Sun, 27 Sep 2026 22:38:53 +0000 Subject: [PATCH 4/4] docs(use-streams): add JSDoc comments to all exported functions (#776) --- hooks/use-streams.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/hooks/use-streams.ts b/hooks/use-streams.ts index ddd4a78..af6fb6e 100644 --- a/hooks/use-streams.ts +++ b/hooks/use-streams.ts @@ -13,6 +13,21 @@ import { readCachedStreams, writeCachedStreams } from '@/lib/streams-cache' // re-fetch without prop-drilling or global state. type Listener = () => void const listeners = new Set() + +/** + * Signals every mounted {@link useStreams} and {@link useStream} instance to + * immediately re-fetch stream data from the contract. + * + * Call this after any write operation (create, withdraw, cancel, top-up, etc.) + * so that the UI reflects the updated state without waiting for the next + * poll tick. Uses a simple in-module listener set — no global state manager + * or prop-drilling required. + * + * @example + * // Inside a component that just created a stream: + * await contractCreateStream(...) + * invalidateStreams() + */ export function invalidateStreams() { listeners.forEach((l) => l()) } @@ -52,6 +67,41 @@ interface UseStreamsOptions { // How long the tab must have been hidden before we show "Refreshing…" const STALE_THRESHOLD_MS = 3_000 +/** + * Fetches and subscribes to all streams associated with the currently + * connected wallet address, split into sent and received categories. + * + * **Return shape:** + * - `sent` — streams where the connected wallet is the sender. + * - `received` — streams where the connected wallet is the recipient. + * - `all` — unfiltered array of every stream returned by the contract. + * - `loading` — `true` while the first (or any subsequent) fetch is in + * flight and no data is available yet. + * - `isRefreshingAfterHidden` — `true` for the duration of the re-fetch that + * fires when the browser tab becomes visible again after being hidden for at + * least 3 seconds. Use this flag to show a non-blocking "Refreshing…" + * indicator without wiping out the existing stream list. + * - `stale` — `true` when `all` is being served from the offline + * {@link readCachedStreams} cache rather than a live network response. + * This can happen when the browser is offline or when a network-level + * fetch fails — FlowStar falls back to the last known data so the user + * sees something instead of an empty dashboard. + * - `lastUpdated` — Unix timestamp (ms) of when the most recent successful + * (or cached) fetch completed, or `null` if no data has been loaded yet. + * - `refetch` — Imperative callback to trigger an immediate re-fetch; also + * called automatically on mount, on address/network change, when + * {@link invalidateStreams} fires, when the tab regains focus, and on + * every poll tick. + * + * **Polling:** Enabled by default every 30 seconds. Polling is paused while + * the tab is hidden and resumes on visibility. Pass `enablePolling: false` or + * a custom `pollInterval` (ms) via `options` to override. + * + * @param options - Optional configuration for the polling behaviour. + * @param options.enablePolling - Whether to poll for updates (default `true`). + * @param options.pollInterval - Polling interval in ms (default `30000`). + * @returns A {@link CategorizedStreams} object. + */ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { const { address } = useWallet() const { network } = useNetwork() @@ -248,6 +298,24 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { } } +/** + * Fetches a single stream by its contract ID and re-fetches whenever + * {@link invalidateStreams} is called. + * + * Unlike {@link useStreams}, this hook does not poll. It fires one fetch on + * mount and again whenever the stream `id`, the active network, or an + * invalidation signal changes. Use `refetch` to trigger a manual refresh + * after a write. + * + * **Return shape:** + * - `stream` — The {@link StreamData} for the given ID, or `null` while + * loading or if the stream was not found. + * - `loading` — `true` while a fetch is in flight. + * - `refetch` — Imperative callback to trigger an immediate re-fetch. + * + * @param id - The contract stream ID to fetch. + * @returns An object with `stream`, `loading`, and `refetch`. + */ export function useStream(id: string): { stream: StreamData | null loading: boolean