Skip to content
Merged

added #662

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
53 changes: 29 additions & 24 deletions dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchActivityFeed, generateMockActivityEvents } from '../services/activityApi';
import type { ActivityEvent, ActivityType } from '../types/activity';
import { formatTimestamp } from '../utils/formatTime';
import { formatRelativeTimestamp, formatTimestamp, parseToDate } from '../utils/formatTime';
import { PaginationControls } from './PaginationControls';
import { useWalletAccountSync } from '../hooks/useWalletAccountSync';
import { EmptyState } from './EmptyState';

// Helper to get icon/color based on activity type
const getActivityTypeStyle = (type: ActivityType) => {
const styles: Record<ActivityType, { color: string; icon: string; bg: string }> = {
'notification_sent': { color: '#34d399', icon: '✓', bg: 'rgba(52, 211, 153, 0.12)' },
'notification_failed': { color: '#f87171', icon: '✕', bg: 'rgba(248, 113, 113, 0.12)' },
'notification_retried': { color: '#f4b400', icon: '↻', bg: 'rgba(244, 180, 0, 0.12)' },
'contract_event_received': { color: '#60a5fa', icon: '📡', bg: 'rgba(96, 165, 250, 0.12)' },
'preference_updated': { color: '#a78bfa', icon: '⚙', bg: 'rgba(167, 139, 250, 0.12)' },
'template_created': { color: '#38bdf8', icon: '📄', bg: 'rgba(56, 189, 248, 0.12)' },
'template_updated': { color: '#22d3ee', icon: '📝', bg: 'rgba(34, 211, 238, 0.12)' },
'webhook_received': { color: '#fb923c', icon: '🔗', bg: 'rgba(251, 146, 60, 0.12)' },
notification_sent: { color: '#34d399', icon: '✓', bg: 'rgba(52, 211, 153, 0.12)' },
notification_failed: { color: '#f87171', icon: '✕', bg: 'rgba(248, 113, 113, 0.12)' },
notification_retried: { color: '#f4b400', icon: '↻', bg: 'rgba(244, 180, 0, 0.12)' },
contract_event_received: { color: '#60a5fa', icon: '📡', bg: 'rgba(96, 165, 250, 0.12)' },
preference_updated: { color: '#a78bfa', icon: '⚙', bg: 'rgba(167, 139, 250, 0.12)' },
template_created: { color: '#38bdf8', icon: '📄', bg: 'rgba(56, 189, 248, 0.12)' },
template_updated: { color: '#22d3ee', icon: '📝', bg: 'rgba(34, 211, 238, 0.12)' },
webhook_received: { color: '#fb923c', icon: '🔗', bg: 'rgba(251, 146, 60, 0.12)' },
};
return styles[type] || styles['contract_event_received'];
};

// Individual activity event card
const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
const style = getActivityTypeStyle(event.type);
const timestamp = parseToDate(event.timestamp);
return (
<div
className={`activity-event ${!event.read ? 'activity-event--unread' : ''}`}
Expand All @@ -42,21 +43,27 @@ const ActivityEventCard = ({ event }: { event: ActivityEvent }) => {
<span className="activity-event__type" style={{ color: style.color }}>
{event.type.replace(/_/g, ' ')}
</span>
<time className="activity-event__time" dateTime={new Date(event.timestamp).toISOString()}>
{formatTimestamp(event.timestamp)}
<time
className="activity-event__time"
dateTime={timestamp?.toISOString()}
title={formatTimestamp(event.timestamp)}
>
{formatRelativeTimestamp(event.timestamp)}
</time>
</div>
<p className="activity-event__message">{event.message}</p>
{Object.keys(event.metadata).length > 0 && (
<div className="activity-event__metadata">
{Object.entries(event.metadata).map(([key, value]) => (
value !== undefined && value !== null && (
<span key={key} className="activity-event__metadata-item">
<span className="activity-event__metadata-key">{key}:</span>
<span className="activity-event__metadata-value">{String(value)}</span>
</span>
)
))}
{Object.entries(event.metadata).map(
([key, value]) =>
value !== undefined &&
value !== null && (
<span key={key} className="activity-event__metadata-item">
<span className="activity-event__metadata-key">{key}:</span>
<span className="activity-event__metadata-value">{String(value)}</span>
</span>
),
)}
</div>
)}
</div>
Expand Down Expand Up @@ -131,8 +138,8 @@ export function ActivityFeed() {

const interval = setInterval(() => {
const [newEvent] = generateMockActivityEvents(1);
setLiveEvents(prev => [newEvent, ...prev]);
setTotal(prev => prev + 1);
setLiveEvents((prev) => [newEvent, ...prev]);
setTotal((prev) => prev + 1);
}, 15000);

return () => clearInterval(interval);
Expand Down Expand Up @@ -211,9 +218,7 @@ export function ActivityFeed() {
description="System events, notification deliveries, and contract activity will appear here as they occur."
/>
) : (
displayedEvents.map(event => (
<ActivityEventCard key={event.id} event={event} />
))
displayedEvents.map((event) => <ActivityEventCard key={event.id} event={event} />)
)}
</div>

Expand Down
38 changes: 22 additions & 16 deletions dashboard/src/components/EventCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo, type KeyboardEvent } from 'react';
import type { BlockchainEvent } from '../types/event';
import { formatTimestamp, formatTimestampShort } from '../utils/formatTime';
import { formatRelativeTimestamp, formatTimestamp } from '../utils/formatTime';
import { CopyButton } from './CopyButton';

export type EventCardVariant = 'compact' | 'expanded';
Expand Down Expand Up @@ -34,13 +34,7 @@ function getEventBadgeClass(name: string | null): string {
}

function SkeletonLine({ width = '100%', height = '14px' }: { width?: string; height?: string }) {
return (
<span
className="event-card__skeleton"
style={{ width, height }}
aria-hidden="true"
/>
);
return <span className="event-card__skeleton" style={{ width, height }} aria-hidden="true" />;
}

function LoadingCard({ variant }: { variant: EventCardVariant }) {
Expand Down Expand Up @@ -104,7 +98,13 @@ function handleActivationKey(onClick: (e: BlockchainEvent) => void, event: Block
};
}

function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e: BlockchainEvent) => void }) {
function CompactCard({
event,
onClick,
}: {
event: BlockchainEvent;
onClick?: (e: BlockchainEvent) => void;
}) {
const displayName = event.eventName ?? event.type;
const badgeClass = getEventBadgeClass(event.eventName);
const Wrapper = onClick ? 'div' : 'article';
Expand All @@ -128,20 +128,24 @@ function CompactCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e:
{shortenAddress(event.contractAddress)}
</span>
<span className="event-card__time" title={formatTimestamp(event.receivedAt)}>
{formatTimestampShort(event.receivedAt)}
{formatRelativeTimestamp(event.receivedAt)}
</span>
</div>
<div className="event-card__details">
<span>Value: {event.value}</span>
{event.txHash && (
<span title={event.txHash}>Tx: {shortenAddress(event.txHash)}</span>
)}
{event.txHash && <span title={event.txHash}>Tx: {shortenAddress(event.txHash)}</span>}
</div>
</Wrapper>
);
}

function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e: BlockchainEvent) => void }) {
function ExpandedCard({
event,
onClick,
}: {
event: BlockchainEvent;
onClick?: (e: BlockchainEvent) => void;
}) {
const displayName = event.eventName ?? event.type;
const badgeClass = getEventBadgeClass(event.eventName);
const Wrapper = onClick ? 'div' : 'article';
Expand Down Expand Up @@ -191,7 +195,9 @@ function ExpandedCard({ event, onClick }: { event: BlockchainEvent; onClick?: (e
<dd>
<ul className="event-card__topics">
{event.topic.map((t, i) => (
<li key={i} className="event-card__topic-item">{t}</li>
<li key={i} className="event-card__topic-item">
{t}
</li>
))}
</ul>
</dd>
Expand Down Expand Up @@ -235,4 +241,4 @@ export const EventCard = memo(function EventCard({
}

return <CompactCard event={event} onClick={onClick} />;
});
});
15 changes: 10 additions & 5 deletions dashboard/src/components/EventRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo } from 'react';
import type { BlockchainEvent } from '../types/event';
import { formatTimestamp } from '../utils/formatTime';
import { formatRelativeTimestamp, formatTimestamp, parseToDate } from '../utils/formatTime';
import { CopyButton } from './CopyButton';

interface EventRowProps {
Expand All @@ -16,9 +16,14 @@ function shortenAddress(address: string): string {

export const EventRow = memo(function EventRow({ event }: EventRowProps) {
const label = event.eventName ?? event.type;
const receivedAt = parseToDate(event.receivedAt);

return (
<article className="event-row" data-event-id={event.eventId} aria-label={`${label}, ledger ${event.ledger}`}>
<article
className="event-row"
data-event-id={event.eventId}
aria-label={`${label}, ledger ${event.ledger}`}
>
<div className="event-row__primary">
<span className="event-row__name">{label}</span>
<span className="event-row__ledger">Ledger {event.ledger}</span>
Expand All @@ -28,10 +33,10 @@ export const EventRow = memo(function EventRow({ event }: EventRowProps) {
<span className="sr-only">Contract: </span>
{shortenAddress(event.contractAddress)}
</span>
<span>
<time dateTime={receivedAt?.toISOString()} title={formatTimestamp(event.receivedAt)}>
<span className="sr-only">Received: </span>
{formatTimestamp(event.receivedAt)}
</span>
{formatRelativeTimestamp(event.receivedAt)}
</time>
<CopyButton value={event.eventId} label="notification ID" size="xs" />
</div>
<div className="event-row__details">
Expand Down
32 changes: 31 additions & 1 deletion dashboard/src/utils/formatTime.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { formatTimestamp, formatTimestampShort, parseToDate } from './formatTime';
import {
formatRelativeTimestamp,
formatTimestamp,
formatTimestampShort,
parseToDate,
RELATIVE_TIMESTAMP_THRESHOLD_MS,
} from './formatTime';

// 2024-01-15 14:30:45 UTC
const FIXED_TIMESTAMP_MS = 1705329045000;
Expand Down Expand Up @@ -140,3 +146,27 @@ describe('formatTimestampShort', () => {
expect(formatTimestampShort('invalid date string')).toBe('Unknown time');
});
});

describe('formatRelativeTimestamp', () => {
const now = FIXED_TIMESTAMP_MS;

it('formats recent past timestamps with relative values', () => {
expect(formatRelativeTimestamp(now - 2 * 60 * 1000, now)).toBe('2 minutes ago');
});

it('formats recent future timestamps correctly', () => {
expect(formatRelativeTimestamp(now + 2 * 60 * 1000, now)).toBe('in 2 minutes');
});

it('uses the exact timestamp at the relative threshold', () => {
expect(formatRelativeTimestamp(now - RELATIVE_TIMESTAMP_THRESHOLD_MS, now)).toContain('2024');
});

it('uses "just now" for timestamps less than one second old', () => {
expect(formatRelativeTimestamp(now - 500, now)).toBe('just now');
});

it('returns the fallback for invalid timestamps', () => {
expect(formatRelativeTimestamp('invalid date string', now)).toBe('Unknown time');
});
});
34 changes: 34 additions & 0 deletions dashboard/src/utils/formatTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const SHORT_OPTIONS: Intl.DateTimeFormatOptions = {
};

const FALLBACK_STRING = 'Unknown time';
export const RELATIVE_TIMESTAMP_THRESHOLD_MS = 24 * 60 * 60 * 1000;

/**
* Parses any raw input (number, string, Date, null, undefined) defensively into a Date object.
Expand Down Expand Up @@ -95,3 +96,36 @@ export function formatTimestampShort(timestamp: unknown): string {
}
return new Intl.DateTimeFormat(undefined, SHORT_OPTIONS).format(d);
}

/**
* Formats timestamps within the last or next 24 hours relative to `now`.
* Older or more distant timestamps use the full, exact timestamp instead.
*/
export function formatRelativeTimestamp(timestamp: unknown, now = Date.now()): string {
const date = parseToDate(timestamp);
if (!date) {
return FALLBACK_STRING;
}

const differenceMs = date.getTime() - now;
if (Math.abs(differenceMs) >= RELATIVE_TIMESTAMP_THRESHOLD_MS) {
return formatTimestamp(date);
}

const differenceSeconds = Math.floor(Math.abs(differenceMs) / 1000);
if (differenceSeconds === 0) {
return 'just now';
}
const unit =
differenceSeconds < 60
? { value: differenceSeconds, label: 'second' }
: differenceSeconds < 3600
? { value: Math.floor(differenceSeconds / 60), label: 'minute' }
: { value: Math.floor(differenceSeconds / 3600), label: 'hour' };
const value = `${unit.value} ${unit.label}${unit.value === 1 ? '' : 's'}`;

if (differenceMs > 0) {
return `in ${value}`;
}
return `${value} ago`;
}
Loading