Skip to content

Commit 58f1082

Browse files
authored
Merge pull request #313 from Shadow-MMN/fix/vault-performance-dynamic-date-filter
fix: replace hardcoded date in VaultPerformanceChart with dynamic time and add testable date utils
2 parents 1afee33 + 3ed9e0c commit 58f1082

3 files changed

Lines changed: 169 additions & 14 deletions

File tree

frontend/src/components/VaultPerformanceChart.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import {
1111
import { TrendingUp } from "./icons";
1212
import { useVaultHistory } from "../hooks/useVaultData";
1313
import Skeleton from "./Skeleton";
14-
15-
type TimeRange = "7D" | "1M" | "3M" | "ALL";
14+
import { type TimeRange, getNow, getCutoffDate } from "../lib/dateUtils";
1615

1716
interface VaultPerformanceTooltipProps {
1817
active?: boolean;
@@ -53,19 +52,9 @@ const VaultPerformanceChart: React.FC = () => {
5352
const filteredData = useMemo(() => {
5453
if (!rawData.length) return [];
5554

56-
const now = new Date("2026-03-25T10:00:00.000Z"); // Use reference date from summary
57-
let daysToSubtract = 0;
58-
59-
switch (timeRange) {
60-
case "7D": daysToSubtract = 7; break;
61-
case "1M": daysToSubtract = 30; break;
62-
case "3M": daysToSubtract = 90; break;
63-
case "ALL": return rawData;
64-
}
65-
66-
const cutoff = new Date(now);
67-
cutoff.setDate(cutoff.getDate() - daysToSubtract);
55+
if (timeRange === "ALL") return rawData;
6856

57+
const cutoff = getCutoffDate(timeRange, getNow());
6958
return rawData.filter(point => new Date(point.date) >= cutoff);
7059
}, [rawData, timeRange]);
7160

frontend/src/lib/dateUtils.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import * as dateUtils from "./dateUtils";
3+
import { getNow, getCutoffDate } from "./dateUtils";
4+
5+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
6+
7+
// Fixed reference instant used across all tests.
8+
const FIXED_NOW = new Date("2026-04-24T12:00:00.000Z");
9+
10+
afterEach(() => {
11+
vi.restoreAllMocks();
12+
});
13+
14+
// ---------------------------------------------------------------------------
15+
// getNow
16+
// ---------------------------------------------------------------------------
17+
describe("getNow", () => {
18+
it("returns the real current time when not mocked", () => {
19+
const before = Date.now();
20+
const result = getNow().getTime();
21+
const after = Date.now();
22+
expect(result).toBeGreaterThanOrEqual(before);
23+
expect(result).toBeLessThanOrEqual(after);
24+
});
25+
26+
it("can be mocked via vi.spyOn for deterministic tests", () => {
27+
vi.spyOn(dateUtils, "getNow").mockReturnValue(FIXED_NOW);
28+
expect(getNow()).toBe(FIXED_NOW);
29+
});
30+
});
31+
32+
// ---------------------------------------------------------------------------
33+
// getCutoffDate
34+
// ---------------------------------------------------------------------------
35+
describe("getCutoffDate", () => {
36+
it("returns a date exactly 7 days before `now` for the 7D range", () => {
37+
const cutoff = getCutoffDate("7D", FIXED_NOW);
38+
expect(cutoff.getTime()).toBe(FIXED_NOW.getTime() - 7 * MS_PER_DAY);
39+
});
40+
41+
it("returns a date exactly 30 days before `now` for the 1M range", () => {
42+
const cutoff = getCutoffDate("1M", FIXED_NOW);
43+
expect(cutoff.getTime()).toBe(FIXED_NOW.getTime() - 30 * MS_PER_DAY);
44+
});
45+
46+
it("returns a date exactly 90 days before `now` for the 3M range", () => {
47+
const cutoff = getCutoffDate("3M", FIXED_NOW);
48+
expect(cutoff.getTime()).toBe(FIXED_NOW.getTime() - 90 * MS_PER_DAY);
49+
});
50+
51+
it("does not mutate the `now` argument", () => {
52+
const now = new Date(FIXED_NOW.getTime());
53+
getCutoffDate("1M", now);
54+
expect(now.getTime()).toBe(FIXED_NOW.getTime());
55+
});
56+
57+
it("uses getNow() when no `now` argument is provided", () => {
58+
// Fake the system clock so new Date() inside getNow() returns FIXED_NOW.
59+
// vi.spyOn on the named export cannot intercept the in-module call used
60+
// by the default parameter, but fake timers mock Date() at the runtime level.
61+
vi.useFakeTimers();
62+
vi.setSystemTime(FIXED_NOW);
63+
try {
64+
const cutoff = getCutoffDate("7D");
65+
expect(cutoff.getTime()).toBe(FIXED_NOW.getTime() - 7 * MS_PER_DAY);
66+
} finally {
67+
vi.useRealTimers();
68+
}
69+
});
70+
});
71+
72+
// ---------------------------------------------------------------------------
73+
// Filtering simulation (the core chart behaviour)
74+
// ---------------------------------------------------------------------------
75+
describe("time-range filtering with mocked system time", () => {
76+
/** Build a synthetic history point `daysAgo` days before FIXED_NOW. */
77+
function makePoint(daysAgo: number) {
78+
return {
79+
date: new Date(FIXED_NOW.getTime() - daysAgo * MS_PER_DAY).toISOString(),
80+
value: 100 + daysAgo,
81+
};
82+
}
83+
84+
const history = [
85+
makePoint(0), // today
86+
makePoint(5), // 5 days ago – inside 7D
87+
makePoint(7), // exactly 7 days ago – boundary (inclusive)
88+
makePoint(8), // 8 days ago – outside 7D, inside 1M
89+
makePoint(29), // 29 days ago – inside 1M
90+
makePoint(30), // exactly 30 days ago – boundary (inclusive)
91+
makePoint(31), // 31 days ago – outside 1M, inside 3M
92+
makePoint(89), // 89 days ago – inside 3M
93+
makePoint(90), // exactly 90 days ago – boundary (inclusive)
94+
makePoint(91), // 91 days ago – outside all finite ranges
95+
];
96+
97+
function filter(range: Exclude<dateUtils.TimeRange, "ALL">) {
98+
const cutoff = getCutoffDate(range, FIXED_NOW);
99+
return history.filter(p => new Date(p.date) >= cutoff);
100+
}
101+
102+
it("7D: keeps points on or after the 7-day cutoff", () => {
103+
const result = filter("7D");
104+
// today (0d), 5d, 7d should be included; 8d+ excluded
105+
expect(result).toHaveLength(3);
106+
const daysAgo = result.map(p =>
107+
Math.round((FIXED_NOW.getTime() - new Date(p.date).getTime()) / MS_PER_DAY),
108+
);
109+
expect(daysAgo).toEqual([0, 5, 7]);
110+
});
111+
112+
it("1M: keeps points on or after the 30-day cutoff", () => {
113+
const result = filter("1M");
114+
// 0d, 5d, 7d, 8d, 29d, 30d included; 31d+ excluded
115+
expect(result).toHaveLength(6);
116+
});
117+
118+
it("3M: keeps points on or after the 90-day cutoff", () => {
119+
const result = filter("3M");
120+
// all except the 91-day point
121+
expect(result).toHaveLength(9);
122+
});
123+
});

frontend/src/lib/dateUtils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Time-range options used by the vault performance chart.
3+
* Defined here so helpers and the chart component share a single source of truth.
4+
*/
5+
export type TimeRange = "7D" | "1M" | "3M" | "ALL";
6+
7+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
8+
9+
/** Days represented by each finite time-range bucket. */
10+
const RANGE_DAYS: Record<Exclude<TimeRange, "ALL">, number> = {
11+
"7D": 7,
12+
"1M": 30,
13+
"3M": 90,
14+
};
15+
16+
/**
17+
* Returns the current time as a Date object.
18+
*
19+
* Extracted into a function so unit tests can mock it without patching
20+
* global `Date` directly:
21+
* vi.spyOn(dateUtils, "getNow").mockReturnValue(new Date("2025-01-01T00:00:00Z"))
22+
*/
23+
export function getNow(): Date {
24+
return new Date();
25+
}
26+
27+
/**
28+
* Computes the cutoff Date for a given finite time range.
29+
*
30+
* Uses timestamp arithmetic instead of `setDate` to avoid mutating the
31+
* `now` argument and to sidestep DST edge-cases on month/year boundaries.
32+
*
33+
* @param range - One of the finite buckets ("7D" | "1M" | "3M").
34+
* @param now - Reference instant (defaults to `getNow()`). Injected by
35+
* callers that need deterministic behaviour (e.g. tests).
36+
* @returns A new Date representing `now` minus the number of days for `range`.
37+
*/
38+
export function getCutoffDate(
39+
range: Exclude<TimeRange, "ALL">,
40+
now: Date = getNow(),
41+
): Date {
42+
return new Date(now.getTime() - RANGE_DAYS[range] * MS_PER_DAY);
43+
}

0 commit comments

Comments
 (0)