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
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ export const HistoryChartSection = ({
tickFormatter,
title,
value,
valueFormatter,
view,
}: {
chartId: string;
onPeriodChange: (period: HistoryPeriod) => void;
tickFormatter: (value: number) => string;
title: string;
value: string;
valueFormatter?: (value: number) => string;
view: YieldHistoryChartView;
}) => (
<Box>
Expand Down Expand Up @@ -60,6 +62,7 @@ export const HistoryChartSection = ({
isRefreshing={view.isRefreshing}
refreshKey={view.period}
tickFormatter={tickFormatter}
valueFormatter={valueFormatter}
/>
</Box>
);
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DateTime } from "effect";
import { useId } from "react";
import { Area, AreaChart, XAxis, YAxis } from "recharts";
import { useTranslation } from "react-i18next";
import { Area, AreaChart, Tooltip, XAxis, YAxis } from "recharts";
import type {
HistoryPeriod,
HistoryPoint,
Expand All @@ -16,6 +17,9 @@ import {
chartContainer,
chartLoadingOverlay,
chartSurface,
chartTooltipContainer,
chartTooltipDate,
chartTooltipValue,
emptyChartContainer,
} from "./styles.css";

Expand All @@ -26,6 +30,7 @@ type Props = {
isRefreshing: boolean;
refreshKey: HistoryPeriod;
tickFormatter: (value: number) => string;
valueFormatter?: (value: number) => string;
};

const height = 150;
Expand All @@ -38,14 +43,71 @@ type EndpointDotProps = {
index?: number;
};

export type ChartTooltipProps = {
active?: boolean;
chartId?: string;
formatValue?: (value: number) => string;
locale?: string;
payload?: ReadonlyArray<{
payload?: HistoryPoint;
value?: number;
}>;
};

export const ChartTooltip = ({
active,
chartId,
formatValue,
locale = "en",
payload,
}: ChartTooltipProps) => {
if (!active || !payload?.length) {
return null;
}

const point = payload[0]?.payload;
if (
!point?.timestamp ||
point.value == null ||
!Number.isFinite(point.value)
) {
return null;
}

const formattedValue = formatValue
? formatValue(point.value)
: `${point.value}`;
const formattedDate = DateTime.formatUtc(point.timestamp, {
locale,
month: "short",
day: "numeric",
year: "numeric",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hover date shifts by timezone

Medium Severity

DateTime.formatLocal converts each history timestamp into the viewer's local timezone. Chart points are UTC midnight daily snapshots, so users west of UTC see the previous calendar day on hover instead of the recorded date. Locale still applies if the UTC calendar day is formatted instead.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7c05eae. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0b9cc6. Snapshot dates now use DateTime.formatUtc, with a regression test forced to America/Los_Angeles that previously rendered the prior calendar day.


return (
<Box className={chartTooltipContainer} data-testid={`${chartId}-tooltip`}>
<Box as="span" className={chartTooltipValue}>
{formattedValue}
</Box>
<Box as="span" className={chartTooltipDate}>
{formattedDate}
</Box>
</Box>
);
};

export const HistoryChart = ({
chartId,
data,
isLoading,
isRefreshing,
refreshKey,
tickFormatter,
valueFormatter,
}: Props) => {
const { i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en";
const formatValue = valueFormatter ?? tickFormatter;
const gradientId = `${chartId}-gradient-${useId().replaceAll(":", "")}`;
const showRefreshChrome = useDelayedBusy(isRefreshing, refreshKey);

Expand Down Expand Up @@ -103,6 +165,26 @@ export const HistoryChart = ({
);
};

const renderActiveDot = ({ cx, cy }: EndpointDotProps) => {
if (cx == null || cy == null) {
return <g key={`${chartId}-active-dot`} />;
}

return (
<g key={`${chartId}-active-dot`}>
<circle cx={cx} cy={cy} fill={accentColor} fillOpacity={0.25} r={6} />
<circle
cx={cx}
cy={cy}
fill={accentColor}
r={3.5}
stroke={vars.color.background}
strokeWidth={1.5}
/>
</g>
);
};

return (
<Box className={chartContainer}>
<Box className={chartSurface({ loading: showRefreshChrome })}>
Expand Down Expand Up @@ -139,8 +221,25 @@ export const HistoryChart = ({
width={46}
/>

<Tooltip
animationDuration={100}
content={
<ChartTooltip
chartId={chartId}
formatValue={formatValue}
locale={locale}
/>
}
cursor={{
stroke: vars.color.tabBorder,
strokeDasharray: "3 3",
strokeWidth: 1,
}}
isAnimationActive={false}
/>

<Area
activeDot={false}
activeDot={renderActiveDot}
dataKey="value"
dot={renderEndpointDot}
fill={`url(#${gradientId})`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,37 @@ export const integrationDocsLink = style([
width: "fit-content",
},
]);

export const chartTooltipContainer = style([
atoms({
borderRadius: "base",
px: "2",
py: "1",
}),
{
backgroundColor: `color-mix(in srgb, ${vars.color.tooltipBackground} 88%, ${vars.color.white})`,
border: `1px solid color-mix(in srgb, ${vars.color.white} 18%, transparent)`,
boxShadow: `0 4px 14px color-mix(in srgb, ${vars.color.tooltipBackground} 45%, transparent), 0 2px 4px color-mix(in srgb, ${vars.color.tooltipBackground} 30%, transparent)`,
display: "flex",
flexDirection: "column",
gap: "2px",
pointerEvents: "none",
whiteSpace: "nowrap",
},
]);

export const chartTooltipValue = style([
atoms({ color: "white" }),
{
fontSize: "12px",
fontWeight: 600,
lineHeight: "16px",
},
]);

export const chartTooltipDate = style({
color: `color-mix(in srgb, ${vars.color.white} 72%, transparent)`,
fontSize: "11px",
fontWeight: 400,
lineHeight: "14px",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { type DateTime, Schema } from "effect";
import { I18nextProvider } from "react-i18next";
import { describe, expect, it } from "vitest";
import { userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { UtcDateTimeFromString } from "../../src/domain/finance/scalars";
import type { HistoryPoint } from "../../src/domain/portfolio/models";
import { HistoryChart } from "../../src/features/earn/ui/dashboard/earn-details/reward-rate-chart";
import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation";

const i18nInstance = createWidgetI18nInstance();

const parseDate = (iso: string): DateTime.Utc =>
Schema.decodeSync(UtcDateTimeFromString)(iso);

const samplePoints: ReadonlyArray<HistoryPoint> = [
{
timestamp: parseDate("2026-06-01T00:00:00.000Z"),
value: 6.5,
},
{
timestamp: parseDate("2026-06-15T00:00:00.000Z"),
value: 6.85,
},
{
timestamp: parseDate("2026-07-01T00:00:00.000Z"),
value: 7.19,
},
];

describe("HistoryChart browser interactions", () => {
it("displays hover container with point details and active dot on mouse move", async () => {
const screen = await render(
<I18nextProvider i18n={i18nInstance}>
<div style={{ width: 400, height: 200 }}>
<HistoryChart
chartId="reward-rate"
data={samplePoints}
isLoading={false}
isRefreshing={false}
refreshKey="30d"
tickFormatter={(val) => `${val.toFixed(2)}%`}
/>
</div>
</I18nextProvider>
);

const surface = screen.container.querySelector("svg.recharts-surface");
expect(surface).toBeTruthy();

if (!surface) return;
// Hover over the chart surface
await userEvent.hover(surface);
const tooltip = screen.getByTestId("reward-rate-tooltip");
await expect.element(tooltip).toBeVisible();
await expect.element(tooltip.getByText("6.85%")).toBeVisible();
await expect.element(tooltip.getByText("Jun 15, 2026")).toBeVisible();

const activeDot = screen.container.querySelector(
'g[key*="reward-rate-active-dot"], circle[r="3.5"]'
);
expect(activeDot).toBeTruthy();
});
});
Loading
Loading