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
5 changes: 5 additions & 0 deletions .changeset/fresh-brooms-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@iota/apps-ui-kit': patch
---

Allow react components to be passed to LabelText
6 changes: 4 additions & 2 deletions apps/core/src/utils/formatDate.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

type Format = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'weekday';
export type Format = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'weekday';
export type SupportedTimeZone = 'UTC';

export function formatDate(
date: Date | number,
format: Format[] = ['day', 'month', 'year', 'hour', 'minute'],
timeZone?: SupportedTimeZone,
): string {
const dateTime = new Date(date);
if (!(dateTime instanceof Date)) return '';
Expand All @@ -28,5 +30,5 @@ export function formatDate(
return responseObj;
}, {});

return new Intl.DateTimeFormat('en-GB', formatOptions).format(dateTime);
return new Intl.DateTimeFormat('en-GB', { ...formatOptions, timeZone }).format(dateTime);
}
10 changes: 7 additions & 3 deletions apps/explorer/src/components/AddressesCardGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Modifications Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { formatDate } from '@iota/core';
import { formatAmount, CoinFormat, formatBalance } from '@iota/iota-sdk/utils';
import type { AllEpochsAddressMetrics } from '@iota/iota-sdk/client';
import { useMemo } from 'react';
Expand All @@ -11,15 +10,20 @@ import { useGetAllEpochAddressMetrics } from '~/hooks/useGetAllEpochAddressMetri
import { LabelTextSize, TooltipPosition } from '@iota/apps-ui-kit';
import { StatisticsPanel } from './StatisticsPanel';
import { GraphTooltipContent } from './GraphTooltipContent';
import { DateDisplay } from './DateDisplay';

const GRAPH_DATA_FIELD = 'cumulativeAddresses';
const GRAPH_DATA_TEXT = 'Total addresses';

function TooltipContent({ data }: { data: AllEpochsAddressMetrics[number] }): JSX.Element {
const dateFormatted = formatDate(new Date(data.timestampMs), ['day', 'month']);
const totalFormatted = formatAmount(data[GRAPH_DATA_FIELD]);

const overline = `${dateFormatted}, Epoch ${data.epoch}`;
const overline = (
<>
<DateDisplay timestamp={data.timestampMs} type="graph" />
{`, Epoch ${data.epoch}`}
</>
);
return (
<GraphTooltipContent
overline={overline}
Expand Down
85 changes: 85 additions & 0 deletions apps/explorer/src/components/DateDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { formatDate, type Format, useTimeAgo } from '@iota/core';
import { useEffect, useRef, useState } from 'react';
import {
GLOBAL_DATE_TYPE,
type DateFormat,
type DateType,
useDateFormat,
} from '~/contexts/dateFormatContext';

const ABSOLUTE_FORMAT: Format[] = ['day', 'month', 'year', 'hour', 'minute', 'second'];

const FORMAT_LABEL: Record<DateFormat, string> = {
default: 'Relative time',
local: 'Local time',
utc: 'UTC',
};

interface DateDisplayProps {
timestamp: number | string;
type?: DateType;
showTimeAgo?: boolean;
showTooltip?: boolean;
showHoverStyle?: boolean;
}

export function DateDisplay({
timestamp,
type,
showTimeAgo,
showTooltip = true,
showHoverStyle = true,
}: DateDisplayProps): JSX.Element {
const effectiveType = type ?? GLOBAL_DATE_TYPE;
const { format, cycle } = useDateFormat(effectiveType);
const timestampMs = Number(timestamp);

const relativeText = useTimeAgo({ timeFrom: timestampMs, shortedTimeLabel: false });
const [showMessage, setShowMessage] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();

useEffect(() => () => clearTimeout(timeoutRef.current), []);

function handleClick() {
cycle();
clearTimeout(timeoutRef.current);

if (showTooltip) {
setShowMessage(true);
timeoutRef.current = setTimeout(() => setShowMessage(false), 1000);
}
}

let displayed: string;
if (format === 'default') {
displayed = relativeText || '--';
} else {
const timeZone = format === 'utc' ? 'UTC' : undefined;
const absolute = formatDate(timestampMs, ABSOLUTE_FORMAT, timeZone);
displayed = showTimeAgo && relativeText ? `${absolute} (${relativeText})` : absolute;
}

return (
<span className="relative inline-block">
{showMessage && (
<span className="tooltip-bg tooltip-text-color absolute bottom-full left-1/2 mb-1 -translate-x-1/2 transform whitespace-nowrap rounded p-xs text-label-sm">
{`${FORMAT_LABEL[format]}`}
</span>
)}
<time
dateTime={new Date(timestampMs).toISOString()}
onClick={handleClick}
className={
showHoverStyle
? 'cursor-pointer select-none text-nowrap rounded-md p-1 hover:bg-iota-neutral-96 dark:hover:bg-iota-neutral-12'
: 'cursor-pointer select-none'
}
>
{displayed || '--'}
</time>
</span>
);
}
2 changes: 1 addition & 1 deletion apps/explorer/src/components/GraphTooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function GraphTooltipContainer({ children }: React.PropsWithChildren): JS

interface GraphTooltipContentProps {
title: string;
overline: string;
overline: React.ReactNode;
subtitle: string;
}
export function GraphTooltipContent({ title, overline, subtitle }: GraphTooltipContentProps) {
Expand Down
10 changes: 7 additions & 3 deletions apps/explorer/src/components/TransactionsCardGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
// Modifications Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { formatDate } from '@iota/core';
import { formatAmount, CoinFormat, formatBalance } from '@iota/iota-sdk/utils';
import { useIotaClientQuery } from '@iota/dapp-kit';
import { LabelTextSize, TooltipPosition } from '@iota/apps-ui-kit';
import { StatisticsPanel } from './StatisticsPanel';
import { GraphTooltipContent } from './GraphTooltipContent';
import { DateDisplay } from './DateDisplay';

interface TooltipContentProps {
data: {
Expand All @@ -20,10 +20,14 @@ interface TooltipContentProps {
function TooltipContent({
data: { epochTotalTransactions, epochStartTimestamp, epoch },
}: TooltipContentProps): JSX.Element {
const dateFormatted = formatDate(new Date(epochStartTimestamp), ['day', 'month']);
const totalFormatted = formatAmount(epochTotalTransactions);

const overline = `${dateFormatted}, Epoch ${epoch}`;
const overline = (
<>
<DateDisplay timestamp={epochStartTimestamp} type="graph" />
{`, Epoch ${epoch}`}
</>
);

return (
<GraphTooltipContent
Expand Down
50 changes: 21 additions & 29 deletions apps/explorer/src/components/home-metrics/CurrentEpoch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,35 @@
// SPDX-License-Identifier: Apache-2.0

import { LabelText, LabelTextSize, Panel, Title } from '@iota/apps-ui-kit';
import { formatDate } from '@iota/core';
import { format, isToday, isYesterday } from 'date-fns';
import { useMemo } from 'react';

import { formatDate, useTimeAgo } from '@iota/core';
import { LinkWithQuery, ProgressBar } from '~/components/ui';
import { useDateFormat } from '~/contexts/dateFormatContext';
import { useGetNetworkMetrics } from '~/hooks';
import { ampli } from '~/lib/utils';
import { useEpochProgress } from '~/pages/epochs/utils';

function useEpochDateSubtitle(start?: number, end?: number, progress?: number, label?: string) {
const { format } = useDateFormat('epoch');

const timestamp = !progress && end ? end : start;
const prefix = !progress && end ? 'End' : 'Started';

const relativeText = useTimeAgo({ timeFrom: timestamp ?? null, shortedTimeLabel: false });
const timeZone = format === 'utc' ? 'UTC' : undefined;
const absoluteText = timestamp
? formatDate(timestamp, ['day', 'month', 'year', 'hour', 'minute', 'second'], timeZone)
: null;

const dateText = format === 'default' ? relativeText : absoluteText;

return !progress && label ? label : dateText ? `${prefix} ${dateText}` : '--';
}

export function CurrentEpoch(): JSX.Element {
const { epoch, progress, label, end, start } = useEpochProgress();
const { data: networkData } = useGetNetworkMetrics();

const formattedDateString = useMemo(() => {
if (!start) {
return null;
}

let formattedDate = '';
const epochStartDate = new Date(start);
if (isToday(epochStartDate)) {
formattedDate = 'Today';
} else if (isYesterday(epochStartDate)) {
formattedDate = 'Yesterday';
} else {
formattedDate = format(epochStartDate, 'PPP');
}
const formattedTime = format(epochStartDate, 'p');
return `${formattedTime}, ${formattedDate}`;
}, [start]);

const epochSubtitle =
!progress && end
? `End ${formatDate(end)}`
: formattedDateString
? `Started ${formattedDateString}`
: '--';
const subtitle = useEpochDateSubtitle(start, end, progress, label);

return (
<LinkWithQuery
Expand All @@ -48,7 +40,7 @@ export function CurrentEpoch(): JSX.Element {
onClick={() => ampli.clickedCurrentEpochCard({ epoch: Number(epoch) })}
>
<Panel>
<Title title={`Epoch ${epoch ?? '--'}`} subtitle={epochSubtitle} />
<Title title={`Epoch ${epoch ?? '--'}`} subtitle={subtitle} />
<div className="flex flex-col gap-md p-md--rs">
<div className="flex flex-row gap-md">
<div className="flex flex-1">
Expand Down
1 change: 1 addition & 0 deletions apps/explorer/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from './validator';

export * from './AddressesCardGraph';
export * from './AreaGraph';
export * from './DateDisplay';
export * from './GraphTooltipContent';
export * from './IotaTokenCard';
export * from './TransactionsCardGraph';
Expand Down
14 changes: 8 additions & 6 deletions apps/explorer/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getNetwork, type Network } from '@iota/iota-sdk/client';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Fragment } from 'react';
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { NetworkContext } from '~/contexts';
import { DateFormatProvider, NetworkContext } from '~/contexts';
import { useAmplitudeIdentity, useNetwork } from '~/hooks';
import { createIotaClient, SupportedNetworks } from '~/lib/utils';
import { TrustFrameworkProvider } from '../trust-framework/trustFrameworkProvider';
Expand Down Expand Up @@ -44,11 +44,13 @@ export function Layout(): JSX.Element {
>
<KioskClientProvider>
<NetworkContext.Provider value={[network, setNetwork]}>
<ThemeProvider appId="iota-explorer">
<Outlet />
<Toaster />
<ReactQueryDevtools />
</ThemeProvider>
<DateFormatProvider>
<ThemeProvider appId="iota-explorer">
<Outlet />
<Toaster />
<ReactQueryDevtools />
</ThemeProvider>
</DateFormatProvider>
</NetworkContext.Provider>
</KioskClientProvider>
</WalletProvider>
Expand Down
78 changes: 78 additions & 0 deletions apps/explorer/src/contexts/dateFormatContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { createContext, useCallback, useContext, useState, type ReactNode } from 'react';

export const GLOBAL_DATE_TYPE = 'global' as const;

export type DateType =
| 'transaction'
| 'epoch'
| 'checkpoint'
| 'package'
| 'table'
| 'graph'
| typeof GLOBAL_DATE_TYPE;

export type DateFormat = 'default' | 'local' | 'utc';

const LS_KEY = 'timeFormat';
const CYCLE: DateFormat[] = ['default', 'local', 'utc'];
const DEFAULT_FORMAT: DateFormat = 'default';

type DateFormatMap = Partial<Record<DateType, DateFormat>>;

interface DateFormatContextValue {
formats: DateFormatMap;
cycle: (type: DateType) => void;
}

function readFromStorage(): DateFormatMap {
try {
const raw = localStorage.getItem(LS_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}

function writeToStorage(map: DateFormatMap): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(map));
} catch (e) {
// storage unavailable (private mode, quota exceeded)
}
}

const DateFormatContext = createContext<DateFormatContextValue>({
formats: {},
cycle: () => {},
});

export function DateFormatProvider({ children }: { children: ReactNode }): JSX.Element {
const [formats, setFormats] = useState<DateFormatMap>(() => readFromStorage());

const cycle = useCallback((type: DateType) => {
setFormats((prev) => {
const current = prev[type] ?? DEFAULT_FORMAT;
const next = CYCLE[(CYCLE.indexOf(current) + 1) % CYCLE.length];
const updated = { ...prev, [type]: next };
writeToStorage(updated);
return updated;
});
}, []);

return (
<DateFormatContext.Provider value={{ formats, cycle }}>
{children}
</DateFormatContext.Provider>
);
}

export function useDateFormat(type: DateType): { format: DateFormat; cycle: () => void } {
const { formats, cycle } = useContext(DateFormatContext);
return {
format: formats[type] ?? DEFAULT_FORMAT,
cycle: () => cycle(type),
};
}
1 change: 1 addition & 0 deletions apps/explorer/src/contexts/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export * from './dateFormatContext';
export * from './networkContext';
export * from './trustFrameworkContext';
Loading
Loading