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
24 changes: 24 additions & 0 deletions dashboard/src/components/EventFiltersBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { memo } from 'react';
import { useEventStore } from '../store/eventStore';
import { useEventCount, useEventFilters, useFilterOptions } from '../hooks/useEventSelectors';
import { SearchAutocomplete } from './SearchAutocomplete';
import type { NotificationSortOption } from '../types/event';

const SORT_OPTIONS: { value: NotificationSortOption; label: string }[] = [
{ value: 'newest', label: 'Newest first' },
{ value: 'oldest', label: 'Oldest first' },
{ value: 'priority', label: 'Priority (on-chain order)' },
{ value: 'delivery_status', label: 'Delivery status' },
];

export const EventFiltersBar = memo(function EventFiltersBar() {
const filters = useEventFilters();
Expand All @@ -10,6 +18,7 @@ export const EventFiltersBar = memo(function EventFiltersBar() {
const setSearch = useEventStore((state) => state.setSearch);
const setContractFilter = useEventStore((state) => state.setContractFilter);
const setEventTypeFilter = useEventStore((state) => state.setEventTypeFilter);
const setSortBy = useEventStore((state) => state.setSortBy);

return (
<section className="event-filters" aria-label="Event filters">
Expand Down Expand Up @@ -53,6 +62,21 @@ export const EventFiltersBar = memo(function EventFiltersBar() {
</select>
</div>

{/* Sort control (#495) */}
<div className="event-filters__group">
<label htmlFor="event-sort">Sort by</label>
<select
id="event-sort"
value={filters.sortBy ?? 'newest'}
onChange={(e) => setSortBy(e.target.value as NotificationSortOption)}
aria-label="Sort notifications"
>
{SORT_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>

<p className="event-filters__count" aria-live="polite" role="status">
{totalCount.toLocaleString()} events loaded
</p> </section>
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/hooks/useEventSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function useFilteredEvents() {
filters.eventType,
filters.status,
filters.dateFrom,
filters.dateTo
filters.dateTo,
filters.txHash,
filters.sortBy ?? 'newest',
),
[events, filters]
);
Expand Down
28 changes: 27 additions & 1 deletion dashboard/src/pages/NotificationSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function NotificationSearchPage() {
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'status'>('newest');

const [response, setResponse] = useState<NotificationSearchResponse | null>(null);
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -86,6 +87,7 @@ export function NotificationSearchPage() {
type: type || undefined,
startDate: dateFrom || undefined,
endDate: dateTo || undefined,
sortBy,
};
}, [
debouncedQuery,
Expand All @@ -96,6 +98,7 @@ export function NotificationSearchPage() {
type,
dateFrom,
dateTo,
sortBy,
]);

const runSearch = useCallback(async () => {
Expand Down Expand Up @@ -127,7 +130,7 @@ export function NotificationSearchPage() {
}, [currentFilters, page, hasParams]);

// Re-run search whenever debounced params change; reset page when filters change
const filtersKey = `${debouncedQuery}|${debouncedSender}|${debouncedTxHash}|${debouncedEventId}|${status}|${type}|${dateFrom}|${dateTo}`;
const filtersKey = `${debouncedQuery}|${debouncedSender}|${debouncedTxHash}|${debouncedEventId}|${status}|${type}|${dateFrom}|${dateTo}|${sortBy}`;
const prevFiltersRef = useRef(filtersKey);
useEffect(() => {
if (filtersKey !== prevFiltersRef.current) {
Expand All @@ -149,6 +152,7 @@ export function NotificationSearchPage() {
setType('');
setDateFrom('');
setDateTo('');
setSortBy('newest');
setPage(1);
setResponse(null);
setError(null);
Expand Down Expand Up @@ -274,6 +278,22 @@ export function NotificationSearchPage() {
</select>
</div>

{/* Sort control (#495) */}
<div className="notif-search-form__group">
<label htmlFor="nsf-sort" className="notif-search-form__label">Sort by</label>
<select
id="nsf-sort"
className="notif-search-form__input"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'newest' | 'oldest' | 'status')}
aria-label="Sort notifications"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="status">Delivery status</option>
</select>
</div>

<div className="notif-search-form__group">
<label htmlFor="nsf-type" className="notif-search-form__label">Notification type</label>
<select
Expand Down Expand Up @@ -497,6 +517,12 @@ function NotificationResultCard({ result }: { result: NotificationSearchResult }
</dd>
</>
)}
{result.failureReason && (
<>
<dt>Failure reason</dt>
<dd className="notif-result-card__failure-reason">{result.failureReason}</dd>
</>
)}
<dt>Created</dt>
<dd>{new Date(result.createdAt).toLocaleString()}</dd>
</dl>
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/services/eventsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export interface NotificationSearchResult {
status: string;
createdAt: string;
payload: string | null;
/**
* Human-readable reason the delivery failed (#493).
* `null` when the notification succeeded or is still pending.
*/
failureReason: string | null;
}

export interface NotificationSearchResponse {
Expand All @@ -65,6 +70,8 @@ export interface NotificationSearchParams {
endDate?: string;
limit?: number;
offset?: number;
/** Sort order for results (#495): newest (default) | oldest | status */
sortBy?: 'newest' | 'oldest' | 'status';
}

export async function fetchEvents(apiUrl: string): Promise<BlockchainEvent[]> {
Expand Down Expand Up @@ -101,6 +108,7 @@ export async function searchNotifications(
if (params.endDate) url.searchParams.set('endDate', params.endDate);
if (params.limit !== undefined) url.searchParams.set('limit', String(params.limit));
if (params.offset !== undefined) url.searchParams.set('offset', String(params.offset));
if (params.sortBy) url.searchParams.set('sortBy', params.sortBy);

const response = await fetch(url.toString());
if (!response.ok) {
Expand Down
33 changes: 32 additions & 1 deletion dashboard/src/store/eventStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
BlockchainEvent,
EventFilters,
NotificationReadFilter,
NotificationSortOption,
NotificationStatus,
} from '../types/event';
import { filterEvents } from '../utils/eventData';
Expand Down Expand Up @@ -30,6 +31,11 @@ interface EventStoreState {
setDateFrom: (dateFrom: string) => void;
setDateTo: (dateTo: string) => void;
setTxHashFilter: (txHash: string) => void;
/**
* Set the active sort order (#495).
* The selection is persisted to localStorage so it survives page refreshes.
*/
setSortBy: (sortBy: NotificationSortOption) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
/**
Expand Down Expand Up @@ -63,6 +69,21 @@ function dedupeEventsById(events: BlockchainEvent[]): BlockchainEvent[] {
return Array.from(byId.values());
}

/** Persist the selected sort order across page refreshes. */
const SORT_STORAGE_KEY = 'notifychain:sortBy';

function loadPersistedSort(): NotificationSortOption {
try {
const saved = localStorage.getItem(SORT_STORAGE_KEY) as NotificationSortOption | null;
if (saved && ['newest', 'oldest', 'priority', 'delivery_status'].includes(saved)) {
return saved;
}
} catch {
// localStorage may be unavailable in some environments
}
return 'newest';
}

export const useEventStore = create<EventStoreState>((set) => ({
events: [],
filters: {
Expand All @@ -73,6 +94,7 @@ export const useEventStore = create<EventStoreState>((set) => ({
dateFrom: '',
dateTo: '',
txHash: '',
sortBy: loadPersistedSort(),
},
isLoading: false,
error: null,
Expand All @@ -97,6 +119,14 @@ export const useEventStore = create<EventStoreState>((set) => ({
set((state) => ({ filters: { ...state.filters, dateTo } })),
setTxHashFilter: (txHash) =>
set((state) => ({ filters: { ...state.filters, txHash } })),
setSortBy: (sortBy) => {
try {
localStorage.setItem(SORT_STORAGE_KEY, sortBy);
} catch {
// localStorage may be unavailable
}
set((state) => ({ filters: { ...state.filters, sortBy } }));
},
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
updateEventStatus: (targetEventId, status) =>
Expand All @@ -120,7 +150,8 @@ export function selectFilteredEvents(state: EventStoreState): BlockchainEvent[]
filters.status,
filters.dateFrom,
filters.dateTo,
filters.txHash
filters.txHash,
filters.sortBy ?? 'newest',
);
}

Expand Down
12 changes: 12 additions & 0 deletions dashboard/src/types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ export interface BlockchainEvent {
/** Read/unread filter for Event Explorer notification lists (not delivery status). */
export type NotificationReadFilter = 'all' | 'read' | 'unread';

/**
* Sort options for the notification list (#495).
*
* - `newest` – most recently received first (default)
* - `oldest` – earliest received first
* - `priority` – events with the highest scheduling priority first
* - `delivery_status` – group by delivery status (active → expired → revoked → undefined)
*/
export type NotificationSortOption = 'newest' | 'oldest' | 'priority' | 'delivery_status';

export interface EventFilters {
search: string;
contractAddress: string;
Expand All @@ -61,4 +71,6 @@ export interface EventFilters {
dateFrom: string; // ISO date string "YYYY-MM-DD" or ""
dateTo: string; // ISO date string "YYYY-MM-DD" or ""
txHash?: string;
/** Active sort order for the notification list (#495). Defaults to "newest". */
sortBy?: NotificationSortOption;
}
55 changes: 52 additions & 3 deletions dashboard/src/utils/eventData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BlockchainEvent } from '../types/event';
import type { BlockchainEvent, NotificationSortOption } from '../types/event';

export function generateMockEvents(count: number): BlockchainEvent[] {
const eventNames = [
Expand Down Expand Up @@ -32,6 +32,52 @@ export function generateMockEvents(count: number): BlockchainEvent[] {
});
}

/**
* Sort comparator for blockchain events (#495).
*
* - newest – most recently received first (default, matches previous behaviour)
* - oldest – earliest received first
* - priority – lower ledger number first (on-chain ordering proxy), then newest within ledger
* - delivery_status – active → expired → revoked → undefined, then newest within each bucket
*/
export function sortEvents(
events: BlockchainEvent[],
sortBy: NotificationSortOption = 'newest',
): BlockchainEvent[] {
const STATUS_ORDER: Record<string, number> = {
active: 0,
expired: 1,
revoked: 2,
};

const copy = [...events];

switch (sortBy) {
case 'oldest':
return copy.sort((a, b) => a.receivedAt - b.receivedAt);

case 'priority':
// Lower ledger number = earlier on-chain = higher priority; break ties newest-first
return copy.sort((a, b) => {
const ledgerDiff = a.ledger - b.ledger;
if (ledgerDiff !== 0) return ledgerDiff;
return b.receivedAt - a.receivedAt;
});

case 'delivery_status':
return copy.sort((a, b) => {
const sa = STATUS_ORDER[a.notificationStatus ?? ''] ?? 3;
const sb = STATUS_ORDER[b.notificationStatus ?? ''] ?? 3;
if (sa !== sb) return sa - sb;
return b.receivedAt - a.receivedAt;
});

case 'newest':
default:
return copy.sort((a, b) => b.receivedAt - a.receivedAt);
}
}

export function filterEvents(
events: BlockchainEvent[],
search: string,
Expand All @@ -40,15 +86,16 @@ export function filterEvents(
status: import('../types/event').NotificationReadFilter = 'all',
dateFrom = '',
dateTo = '',
txHash = ''
txHash = '',
sortBy: NotificationSortOption = 'newest',
): BlockchainEvent[] {
const normalizedSearch = search.trim().toLowerCase();
const normalizedTxHash = txHash.trim().toLowerCase();
const fromMs = dateFrom ? new Date(dateFrom).getTime() : 0;
// dateTo is inclusive: include the entire day
const toMs = dateTo ? new Date(dateTo).getTime() + 86_399_999 : Infinity;

return events.filter((event) => {
const filtered = events.filter((event) => {
if (contractAddress !== 'all' && event.contractAddress !== contractAddress) return false;
if (eventType !== 'all' && event.eventName !== eventType) return false;

Expand All @@ -68,4 +115,6 @@ export function filterEvents(
event.txHash?.toLowerCase().includes(normalizedSearch)
);
});

return sortEvents(filtered, sortBy);
}
Loading