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
80 changes: 51 additions & 29 deletions src/app/quote/Client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TextField } from '@/components/TextField';
import { SlippageView } from './Slippage';
import { apiFetch, type ApiError } from '@/lib/apiClient';
Expand All @@ -14,6 +14,14 @@ import {
type HistoryEntry,
type QuoteInputs,
} from './QuoteHistory';
import {
canonicalEntryFromQuote,
mergePendingEntry,
pushHistoryPure,
readHistory,
writeHistory,
type PendingHistoryEntry,
} from './historyModel';

type FieldErrors = {
source?: string;
Expand All @@ -22,10 +30,10 @@ type FieldErrors = {
};

const INPUTS_KEY = 'stableroute.quote.inputs';
const HISTORY_KEY = 'stableroute.quote.history';
const MAX_HISTORY = 5;
const ASSET_CODE_PATTERN = /^[A-Za-z0-9]{1,12}$/;
const MIN_SUBMIT_INTERVAL_MS = 1_000;
const ROLLBACK_MESSAGE =
'The recent quotes update failed and was rolled back.';

function normalizeAssetCode(value: string): string | null {
const trimmed = value.trim();
Expand All @@ -46,29 +54,9 @@ function isQuoteInputs(value: unknown): value is QuoteInputs {
);
}

function readHistory(): HistoryEntry[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as HistoryEntry[];
return Array.isArray(parsed) ? parsed.slice(0, MAX_HISTORY) : [];
} catch {
return [];
}
}

function pushHistory(entry: QuoteInputs) {
const next: HistoryEntry[] = [
{ ...entry, savedAt: Date.now() },
...readHistory().filter(
(item) =>
item.source !== entry.source ||
item.dest !== entry.dest ||
item.amount !== entry.amount
),
].slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(next));
return next;
function pushHistory(entry: QuoteInputs): HistoryEntry[] {
// Confirmed writes only — optimistic entries never reach localStorage.
return writeHistory(pushHistoryPure(readHistory(), entry));
}

export default function QuoteClient() {
Expand All @@ -81,6 +69,9 @@ export default function QuoteClient() {
const [destAsset, setDestAsset] = useState('');
const [amount, setAmount] = useState('');
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [pendingEntry, setPendingEntry] = useState<PendingHistoryEntry | null>(
null
);
const [quote, setQuote] = useState<Quote | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState<string | null>(null);
Expand Down Expand Up @@ -183,6 +174,17 @@ export default function QuoteClient() {
const currentRequestId = activeRequestRef.current + 1;
activeRequestRef.current = currentRequestId;

// Optimistic mutation (#723): reflect the requested quote in Recent
// quotes before the server responds. Rendered via mergePendingEntry and
// never persisted; reconciled or rolled back when the request settles.
// A newer submission overwrites this single slot, implicitly discarding
// the stale one.
setPendingEntry({
...inputs,
savedAt: now,
key: `pending-${currentRequestId}`,
});

setLoading(true);
announce('Requesting quote…');
try {
Expand All @@ -197,7 +199,11 @@ export default function QuoteClient() {
);
if (currentRequestId !== activeRequestRef.current) return;
setQuote(body);
setHistory(pushHistory(inputs));
// Reconcile (#723): the confirmed entry built from the server's
// response fields replaces the optimistic row; only now is anything
// written to localStorage.
setHistory(pushHistory(canonicalEntryFromQuote(body)));
setPendingEntry(null);
announce('Quote received.');
const rateDisplay = formatQuoteRateDisplay(body.estimated_rate).display;
const now = Date.now();
Expand All @@ -210,10 +216,15 @@ export default function QuoteClient() {
} catch (err) {
if (currentRequestId !== activeRequestRef.current) return;
if (controller.signal.aborted) return;
// Roll back (#723): drop the optimistic row so the rendered history is
// exactly what it was before the submission. Only this slot is
// cleared — unrelated state (form fields, confirmed rows, storage)
// was never touched by the mutation.
setPendingEntry(null);
const apiError = err as ApiError & { requestId?: string };
setFormError(apiError.message ?? 'quote request failed');
setRequestId(apiError.requestId ?? null);
announce('');
announce(ROLLBACK_MESSAGE);
const failTime = Date.now();
if (failTime - lastAnnounceAtRef.current >= 300) {
lastAnnounceAtRef.current = failTime;
Expand Down Expand Up @@ -246,6 +257,13 @@ export default function QuoteClient() {
onSubmit(new Event('submit') as any);
}, [onSubmit]);

// Stable identity across unrelated re-renders keeps QuoteHistory's memo
// effective; the pending entry (if any) leads the rendered rows.
const historyView = useMemo(
() => mergePendingEntry(history, pendingEntry),
[history, pendingEntry]
);

return (
<main
id="main-content"
Expand All @@ -259,7 +277,11 @@ export default function QuoteClient() {
</p>
</header>

<QuoteHistory history={history} onSelect={applyInputs} />
<QuoteHistory
history={historyView}
hasPendingEntry={pendingEntry !== null}
onSelect={applyInputs}
/>

<form onSubmit={onSubmit} className="flex flex-col gap-3">
<TextField
Expand Down
185 changes: 30 additions & 155 deletions src/app/quote/QuoteHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
'use client';

import React, { memo } from 'react';
import { EmptyState } from '@/components/EmptyState';
import {
applyViewState,
distinctSources,
nextSortDir,
SORT_COLUMNS,
type HistoryEntry,
type QuoteInputs,
type SortColumn,
type SortDir,
} from './tableModel';
import { useTableViewState } from './useTableViewState';
import type { HistoryEntry, QuoteInputs } from './historyModel';

export type { HistoryEntry, QuoteInputs } from './tableModel';
export type { HistoryEntry, QuoteInputs } from './historyModel';

export interface QuoteHistoryProps {
history: HistoryEntry[];
onSelect: (entry: HistoryEntry) => void;
/** True while the first row is an optimistic (unconfirmed) entry. */
hasPendingEntry?: boolean;
}

const COLUMN_LABELS: Record<SortColumn, string> = {
Expand Down Expand Up @@ -46,6 +37,7 @@ function ariaSortValue(dir: SortDir): 'ascending' | 'descending' | 'none' {
export const QuoteHistory = memo(function QuoteHistory({
history,
onSelect,
hasPendingEntry = false,
}: QuoteHistoryProps) {
const { view, update, filterInput, setFilterInput } = useTableViewState();

Expand All @@ -63,149 +55,32 @@ export const QuoteHistory = memo(function QuoteHistory({
<h2 id="recent-quotes-heading" className="text-sm font-medium">
Recent quotes
</h2>

<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-sm">
<span>Filter quotes</span>
<input
type="search"
value={filterInput}
onChange={(event) => setFilterInput(event.target.value)}
placeholder="Search by asset code"
className="rounded-md border border-neutral-300 px-3 py-1.5 dark:border-neutral-700 dark:bg-neutral-900"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span>Source asset</span>
<select
value={view.asset}
onChange={(event) => update({ asset: event.target.value })}
className="rounded-md border border-neutral-300 px-2 py-1.5 dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="all">All</option>
{sources.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
</label>
</div>

{history.length === 0 ? (
<EmptyState
title="No recent quotes yet"
description="Submit a quote above and it will be listed here."
/>
) : derived.totalFiltered === 0 ? (
<EmptyState
title="No quotes match your filters"
description="Try a different search term or source asset."
/>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<caption className="sr-only">
Recent quotes. Column headers are sortable.
</caption>
<thead>
<tr>
{SORT_COLUMNS.map((column) => {
const active = view.sort === column && view.dir !== 'none';
return (
<th
key={column}
scope="col"
aria-sort={
view.sort === column
? ariaSortValue(view.dir)
: 'none'
}
className="py-1 pr-4 font-medium"
>
<button
type="button"
onClick={() =>
update({
sort: column,
dir:
view.sort === column
? nextSortDir(view.dir)
: 'asc',
})
}
className="hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
{COLUMN_LABELS[column]}
{active && (view.dir === 'asc' ? ' ↑' : ' ↓')}
</button>
</th>
);
})}
<th scope="col" className="py-1 font-medium">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{derived.rows.map((entry) => {
const label = `${entry.source} → ${entry.dest} · ${entry.amount}`;
return (
<tr
key={`${entry.source}-${entry.dest}-${entry.amount}-${entry.savedAt}`}
className="border-t border-neutral-200 dark:border-neutral-800"
>
<td className="py-1.5 pr-4 font-mono">{entry.source}</td>
<td className="py-1.5 pr-4 font-mono">{entry.dest}</td>
<td className="py-1.5 pr-4">{entry.amount}</td>
<td className="py-1.5 pr-4">
<time dateTime={new Date(entry.savedAt).toISOString()}>
{new Date(entry.savedAt).toLocaleString()}
</time>
</td>
<td className="py-1.5">
<button
type="button"
onClick={() => onSelect(entry)}
aria-label={`Use quote ${label}`}
className="rounded border px-3 py-1 text-xs hover:border-neutral-400 dark:border-neutral-700"
>
Use
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<nav
aria-label="Quotes pagination"
className="flex items-center gap-3 text-sm"
>
<button
type="button"
onClick={() => update({ page: derived.page - 1 })}
disabled={derived.page <= 1}
className="rounded border px-3 py-1 text-xs disabled:opacity-50 dark:border-neutral-700"
>
Previous
</button>
<span aria-live="polite">
Page {derived.page} of {derived.totalPages}
</span>
<button
type="button"
onClick={() => update({ page: derived.page + 1 })}
disabled={derived.page >= derived.totalPages}
className="rounded border px-3 py-1 text-xs disabled:opacity-50 dark:border-neutral-700"
<ul className="flex flex-col gap-1">
{history.map((entry, index) => {
// The pending entry is always merged at index 0 (see
// mergePendingEntry); render it dimmed with a non-visual hint.
const isPending = hasPendingEntry && index === 0;
return (
<li
key={`${entry.source}-${entry.dest}-${entry.amount}-${entry.savedAt}`}
data-pending={isPending || undefined}
>
Next
</button>
</nav>
</>
)}
<button
type="button"
onClick={() => onSelect(entry)}
className={`w-full rounded border border-neutral-200 px-3 py-2 text-left text-sm hover:border-neutral-400 dark:border-neutral-800${
isPending ? ' opacity-60' : ''
}`}
>
{entry.source} → {entry.dest} · {entry.amount}
{isPending && (
<span className="sr-only"> (saving…)</span>
)}
</button>
</li>
);
})}
</ul>
</section>
);
});
Loading
Loading