Implement a bucket size setting for the charts

This commit is contained in:
2026-01-15 22:29:09 +01:00
parent eca45af026
commit 77cc4663f1
5 changed files with 433 additions and 105 deletions

View File

@@ -5,11 +5,11 @@ import { FetchActiveJobs, FetchCharacterNames } from '@/lib/clickhouse-client';
import { SettingsDialog } from '@/components/SettingsDialog';
import { StatsBar } from '@/components/StatsBar';
import { useNotifications } from '@/hooks/useNotifications';
import { RefreshCw, Factory, List, BarChart3, Package, History, Users, ClipboardList, Boxes, FileText, Receipt } from 'lucide-react';
import { RefreshCw, Factory, List, BarChart3, Package, History, Users, ClipboardList, Boxes, FileText, Receipt, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { createContext, useContext } from 'react';
import { GetInvTypes } from '@/lib/typesense-client';
import { ManufacturingJob } from '@/lib/clickhouse-model';
import { parseDuration, formatDuration, ParsedDuration } from '@/lib/duration-utils';
const SELECTED_CHARS_KEY = 'eve-industry-selected-characters';
@@ -33,6 +33,21 @@ const DashboardContext = createContext<DashboardContextType>({
export const useDashboard = () => useContext(DashboardContext);
// Duration context for transactions page
const TimeGranularityContext = createContext<{
duration: ParsedDuration | null;
durationString: string;
setDurationString: (value: string) => void;
}>({
duration: null,
durationString: '1d',
setDurationString: () => {},
});
export const useTimeGranularity = () => useContext(TimeGranularityContext);
const DURATION_STORAGE_KEY = 'eve-industry-transaction-duration';
export function DashboardLayout() {
const hasInitialized = useRef(false);
const location = useLocation();
@@ -41,6 +56,22 @@ export function DashboardLayout() {
const [showAllCharacters, setShowAllCharacters] = useState(false);
const [isRefetching, setIsRefetching] = useState(false);
const [refetchError, setRefetchError] = useState(false);
// Duration state for transactions page (persisted in localStorage)
const [durationString, setDurationStringState] = useState<string>(() => {
const saved = localStorage.getItem(DURATION_STORAGE_KEY);
return saved || '1d';
});
const parsedDuration = useMemo(() => parseDuration(durationString), [durationString]);
const setDurationString = useCallback((value: string) => {
const parsed = parseDuration(value);
if (parsed) {
setDurationStringState(value);
localStorage.setItem(DURATION_STORAGE_KEY, value);
}
}, []);
const {
data: jobs = [],
@@ -151,9 +182,82 @@ export function DashboardLayout() {
{ path: '/characters', label: 'Characters', icon: Users },
];
// Duration input component (styled like EditableCell)
function DurationInput({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLDivElement>(null);
// Sync value to contentEditable when value changes externally (but not while editing)
useEffect(() => {
if (inputRef.current && !isEditing) {
inputRef.current.textContent = value;
}
}, [value, isEditing]);
const handleBlur = () => {
setIsEditing(false);
if (!inputRef.current) return;
const textContent = inputRef.current.textContent || '';
const parsed = parseDuration(textContent);
if (parsed) {
const formatted = formatDuration(parsed);
onChange(formatted);
// Update the display to show formatted value
if (inputRef.current) {
inputRef.current.textContent = formatted;
}
} else {
// Revert on invalid
if (inputRef.current) {
inputRef.current.textContent = value;
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
inputRef.current?.blur();
}
if (e.key === 'Escape') {
if (inputRef.current) {
inputRef.current.textContent = value;
}
setIsEditing(false);
inputRef.current?.blur();
}
};
// Initialize contentEditable with value on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.textContent = value;
}
}, []);
return (
<div
ref={inputRef}
contentEditable
suppressContentEditableWarning
onFocus={() => {
setIsEditing(true);
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={cn(
"px-2 py-1 cursor-text focus:outline-none focus:ring-1 focus:ring-primary rounded min-w-[60px] text-foreground",
isEditing ? "bg-secondary" : "hover:bg-secondary/50"
)}
/>
);
}
return (
<DashboardContext.Provider value={{ jobs, filteredJobs, isLoading, showAllCharacters, selectedCharacters, characters }}>
<div className="min-h-screen bg-background">
<TimeGranularityContext.Provider value={{ duration: parsedDuration, durationString, setDurationString }}>
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
<div className="container mx-auto px-4 py-4">
@@ -222,30 +326,45 @@ export function DashboardLayout() {
</div>
</div>
</div>
</header>
{/* Navigation */}
<nav className="border-b border-border bg-card/30">
<div className="container mx-auto px-4">
<div className="flex gap-1 overflow-x-auto py-2">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => cn(
"flex items-center gap-2 px-4 py-2 rounded-md transition-all duration-200 whitespace-nowrap",
isActive
? "bg-primary/20 text-primary border border-primary/50"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<item.icon className="w-4 h-4" />
{item.label}
</NavLink>
))}
{/* Navigation */}
<nav className="border-b border-border bg-card/30">
<div className="container mx-auto px-4">
<div className="flex gap-1 overflow-x-auto py-2">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => cn(
"flex items-center gap-2 px-4 py-2 rounded-md transition-all duration-200 whitespace-nowrap",
isActive
? "bg-primary/20 text-primary border border-primary/50"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<item.icon className="w-4 h-4" />
{item.label}
</NavLink>
))}
</div>
</div>
</div>
</nav>
</nav>
{/* Duration Picker - Only visible on transactions page */}
{location.pathname === '/transactions' && (
<div className="border-b border-border bg-card/20">
<div className="container mx-auto px-4 py-2">
<div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Bucket Size:</span>
</div>
<DurationInput value={durationString} onChange={setDurationString} />
</div>
</div>
</div>
)}
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-6 space-y-6">
@@ -298,6 +417,7 @@ export function DashboardLayout() {
</div>
</footer>
</div>
</TimeGranularityContext.Provider>
</DashboardContext.Provider>
);
}

View File

@@ -23,6 +23,7 @@ import {
PriceStatistics,
PriceStatisticsByTime,
} from "./clickhouse-model";
import { ParsedDuration, durationToClickHouseBucket } from "./duration-utils";
const CLICKHOUSE_URL = "https://mclickhouse.site.quack-lab.dev";
const CLICKHOUSE_USER_INDY = { user: "indy_jobs_ro_user", password: "Q9Cd5Z3j72NypTdNwKV7E7H83mv35mRc" };
@@ -607,15 +608,9 @@ GROUP BY toDate(wj.date) ORDER BY toDate(wj.date) ASC FORMAT JSON`,
export type TimeGranularity = 'hour' | 'day' | '3days' | '7days';
export async function FetchVolumeByGranularity(granularity: TimeGranularity = 'day', days: number = 30): Promise<DailyVolume[]> {
// Map granularity to ClickHouse time functions
const granularityMap = {
hour: 'toStartOfHour(wj.date)',
day: 'toDate(wj.date)',
'3days': 'toDate(intDiv(toUInt32(toDate(wj.date)), 3) * 3)',
'7days': 'toDate(intDiv(toUInt32(toDate(wj.date)), 7) * 7)',
};
const groupExpr = granularityMap[granularity];
export async function FetchVolumeByGranularity(duration: ParsedDuration | null, days: number = 30): Promise<DailyVolume[]> {
// Use duration for bucket size, or fallback to day if not provided
const groupExpr = duration ? durationToClickHouseBucket(duration) : 'toDate(wj.date)';
return queryClickHouse<DailyVolume>(
`
@@ -707,15 +702,9 @@ WHERE wj.date >= now() - INTERVAL ${days} DAY AND wt.journal_ref_id IS NOT NULL
return result[0];
}
export async function FetchPriceStatisticsByGranularity(granularity: TimeGranularity = 'day', days: number = 30): Promise<PriceStatisticsByTime[]> {
// Map granularity to ClickHouse time functions
const granularityMap = {
hour: 'toStartOfHour(wj.date)',
day: 'toDate(wj.date)',
'3days': 'toDate(intDiv(toUInt32(toDate(wj.date)), 3) * 3)',
'7days': 'toDate(intDiv(toUInt32(toDate(wj.date)), 7) * 7)',
};
const groupExpr = granularityMap[granularity];
export async function FetchPriceStatisticsByGranularity(duration: ParsedDuration | null, days: number = 30): Promise<PriceStatisticsByTime[]> {
// Use duration for bucket size, or fallback to day if not provided
const groupExpr = duration ? durationToClickHouseBucket(duration) : 'toDate(wj.date)';
return queryClickHouse<PriceStatisticsByTime>(
`

View File

@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import { parseDuration, formatDuration, durationToClickHouseBucket, ParsedDuration } from './duration-utils';
describe('parseDuration', () => {
it('parses single day', () => {
const result = parseDuration('1d');
expect(result).toEqual({ days: 1 });
});
it('parses single hour', () => {
const result = parseDuration('10h');
expect(result).toEqual({ hours: 10 });
});
it('parses single minute', () => {
const result = parseDuration('10m');
expect(result).toEqual({ minutes: 10 });
});
it('parses days and hours', () => {
const result = parseDuration('3d 10h');
expect(result).toEqual({ days: 3, hours: 10 });
});
it('parses days and minutes', () => {
const result = parseDuration('1d 10m');
expect(result).toEqual({ days: 1, minutes: 10 });
});
it('parses months before days', () => {
const result = parseDuration('10m 1d');
expect(result).toEqual({ months: 10, days: 1 });
});
it('parses months before days with minutes after', () => {
const result = parseDuration('10m 1d 10m');
expect(result).toEqual({ months: 10, days: 1, minutes: 10 });
});
it('parses multiple units', () => {
const result = parseDuration('1d 2h 30m 45s');
expect(result).toEqual({ days: 1, hours: 2, minutes: 30, seconds: 45 });
});
it('parses weeks', () => {
const result = parseDuration('2w');
expect(result).toEqual({ days: 14 });
});
it('parses years', () => {
const result = parseDuration('1y');
expect(result).toEqual({ months: 12 });
});
it('returns null for empty string', () => {
const result = parseDuration('');
expect(result).toBeNull();
});
it('returns null for invalid format', () => {
const result = parseDuration('invalid');
expect(result).toBeNull();
});
it('handles case insensitive input', () => {
const result = parseDuration('1D 10H');
expect(result).toEqual({ days: 1, hours: 10 });
});
it('handles whitespace', () => {
const result = parseDuration(' 1d 10h ');
expect(result).toEqual({ days: 1, hours: 10 });
});
});
describe('formatDuration', () => {
it('formats single day', () => {
const result = formatDuration({ days: 1 });
expect(result).toBe('1d');
});
it('formats days and hours', () => {
const result = formatDuration({ days: 3, hours: 10 });
expect(result).toBe('3d 10h');
});
it('formats months before days', () => {
const result = formatDuration({ months: 10, days: 1, minutes: 10 });
expect(result).toBe('10m 1d 10m');
});
it('formats all units', () => {
const result = formatDuration({ months: 2, days: 1, hours: 10, minutes: 30, seconds: 45 });
expect(result).toBe('2m 1d 10h 30m 45s');
});
});
describe('durationToClickHouseBucket', () => {
it('converts days and hours to seconds', () => {
const duration = parseDuration('3d 10h');
expect(duration).toEqual({ days: 3, hours: 10 });
const bucket = durationToClickHouseBucket(duration!);
// 3 days = 259200 seconds, 10 hours = 36000 seconds, total = 295200 seconds
expect(bucket).toContain('295200 SECOND');
});
it('converts single day to seconds', () => {
const duration = parseDuration('1d');
const bucket = durationToClickHouseBucket(duration!);
// 1 day = 86400 seconds
expect(bucket).toContain('86400 SECOND');
});
it('converts multiple days to seconds', () => {
const duration = parseDuration('3d');
const bucket = durationToClickHouseBucket(duration!);
// 3 days = 259200 seconds
expect(bucket).toContain('259200 SECOND');
});
it('converts hours to seconds', () => {
const duration = parseDuration('10h');
const bucket = durationToClickHouseBucket(duration!);
// 10 hours = 36000 seconds
expect(bucket).toContain('36000 SECOND');
});
it('converts minutes to seconds', () => {
const duration = parseDuration('30m');
const bucket = durationToClickHouseBucket(duration!);
// 30 minutes = 1800 seconds
expect(bucket).toContain('1800 SECOND');
});
it('converts seconds to seconds', () => {
const duration = parseDuration('45s');
const bucket = durationToClickHouseBucket(duration!);
expect(bucket).toContain('45 SECOND');
});
it('converts 1 second to 1 second', () => {
const duration = parseDuration('1s');
const bucket = durationToClickHouseBucket(duration!);
expect(bucket).toContain('1 SECOND');
});
});

116
src/lib/duration-utils.ts Normal file
View File

@@ -0,0 +1,116 @@
// Duration parsing and formatting utilities
// Format: "1d", "12d 23h", "17m" (17 months), "1d 10m" (1 day 10 minutes)
// 'm' is months ONLY if it comes before 'd', otherwise it's minutes
export interface ParsedDuration {
months?: number;
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
}
export function parseDuration(input: string): ParsedDuration | null {
const trimmed = input.trim().toLowerCase();
if (!trimmed) return null;
// Match patterns like: "1d", "12d 23h", "10m", "1d 10m", "10m 1d 10m"
// 'm' is minutes by default, but months if it comes BEFORE 'd'
const parts = trimmed.split(/\s+/);
const result: ParsedDuration = {};
// Find the index of the first 'd' part
const firstDayIndex = parts.findIndex(p => p.match(/^(\d+)([d])$/));
// Parse all parts
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const match = part.match(/^(\d+)([ymwdhms])$/);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 'y':
if (result.months !== undefined) return null;
result.months = (result.months || 0) + value * 12;
break;
case 'm':
// 'm' is minutes by default, but months if it comes BEFORE 'd'
if (firstDayIndex !== -1 && i < firstDayIndex) {
// 'm' comes before 'd', so it's months
if (result.months !== undefined) return null;
result.months = value;
} else {
// 'm' comes after 'd' or no 'd' exists, so it's minutes
if (result.minutes !== undefined) return null;
result.minutes = value;
}
break;
case 'w':
if (result.days !== undefined) return null;
result.days = (result.days || 0) + value * 7;
break;
case 'd':
if (result.days !== undefined) return null;
result.days = (result.days || 0) + value;
break;
case 'h':
if (result.hours !== undefined) return null;
result.hours = value;
break;
case 's':
if (result.seconds !== undefined) return null;
result.seconds = value;
break;
default:
return null;
}
}
return Object.keys(result).length > 0 ? result : null;
}
export function formatDuration(duration: ParsedDuration): string {
const parts: string[] = [];
if (duration.months) parts.push(`${duration.months}m`);
if (duration.days) parts.push(`${duration.days}d`);
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds) parts.push(`${duration.seconds}s`);
return parts.join(' ');
}
// Convert duration to total seconds
function durationToSeconds(duration: ParsedDuration): number {
let total = 0;
if (duration.months) total += duration.months * 30 * 24 * 60 * 60; // Approximate: 30 days per month
if (duration.days) total += duration.days * 24 * 60 * 60;
if (duration.hours) total += duration.hours * 60 * 60;
if (duration.minutes) total += duration.minutes * 60;
if (duration.seconds) total += duration.seconds;
return total;
}
// Convert duration to ClickHouse bucket expression
export function durationToClickHouseBucket(duration: ParsedDuration, dateColumn: string = 'wj.date'): string {
// If we have months only, use month-based bucketing
if (duration.months !== undefined && duration.days === undefined && duration.hours === undefined && duration.minutes === undefined && duration.seconds === undefined) {
return `toStartOfMonth(${dateColumn}) + INTERVAL ${duration.months} MONTH * toInt32(floor((toRelativeMonthNum(${dateColumn}) - toRelativeMonthNum(toStartOfYear(${dateColumn}))) / ${duration.months}))`;
}
// Convert everything to seconds
const totalSeconds = durationToSeconds(duration);
// Use seconds for the bucket
return `toStartOfInterval(${dateColumn}, INTERVAL ${totalSeconds} SECOND)`;
}
// Check if duration represents a time unit that needs hour-level formatting
export function durationHasHours(duration: ParsedDuration | null): boolean {
if (!duration) return false;
return duration.seconds !== undefined || duration.minutes !== undefined || duration.hours !== undefined;
}

View File

@@ -9,8 +9,9 @@ import {
FetchTransactionHeatmap,
FetchPriceStatistics,
FetchPriceStatisticsByGranularity,
TimeGranularity
} from '@/lib/clickhouse-client';
import { useTimeGranularity } from '@/components/DashboardLayout';
import { durationHasHours } from '@/lib/duration-utils';
import type {
TransactionStats,
DailyVolume,
@@ -290,7 +291,8 @@ export default function TransactionsPage() {
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [typeNames, setTypeNames] = useState<Map<number, InvType>>(new Map());
const [iconUrls, setIconUrls] = useState<Map<number, string>>(new Map());
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>('day');
const { duration } = useTimeGranularity();
const showHours = durationHasHours(duration);
// Keep refs in sync so our loaders can be stable (deps []) but still see latest cache.
const typeNamesRef = useRef(typeNames);
@@ -308,8 +310,8 @@ export default function TransactionsPage() {
});
const { data: dailyVolume = [], isLoading: volumeLoading } = useQuery<DailyVolume[]>({
queryKey: ['wallet-daily-volume', timeGranularity],
queryFn: () => FetchVolumeByGranularity(timeGranularity, 30),
queryKey: ['wallet-daily-volume', duration, 30],
queryFn: () => FetchVolumeByGranularity(duration, 30),
refetchInterval: 120000,
});
@@ -335,14 +337,14 @@ export default function TransactionsPage() {
// Price statistics query by granularity
const { data: priceStatsByTime = [], isLoading: priceStatsLoading } = useQuery<PriceStatisticsByTime[]>({
queryKey: ['price-statistics', timeGranularity],
queryFn: () => FetchPriceStatisticsByGranularity(timeGranularity, 30),
queryKey: ['price-statistics', duration, 30],
queryFn: () => FetchPriceStatisticsByGranularity(duration, 30),
refetchInterval: 120000,
});
// Transaction heatmap query
const { data: heatmapData = [], isLoading: heatmapLoading } = useQuery<TransactionHeatmap[]>({
queryKey: ['transaction-heatmap'],
queryKey: ['transaction-heatmap', 30],
queryFn: () => FetchTransactionHeatmap(30),
refetchInterval: 120000,
});
@@ -602,21 +604,6 @@ export default function TransactionsPage() {
<PageSection
title="Transaction Volume"
icon={TrendingUp}
headerRight={
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<select
value={timeGranularity}
onChange={(e) => setTimeGranularity(e.target.value as TimeGranularity)}
className="eve-input px-2 py-1 text-sm min-w-[100px]"
>
<option value="hour">1H</option>
<option value="day">1D</option>
<option value="3days">3D</option>
<option value="7days">7D</option>
</select>
</div>
}
>
<div className="p-4 h-96">
<ResponsiveContainer width="100%" height="100%">
@@ -639,8 +626,8 @@ export default function TransactionsPage() {
tickFormatter={(value) => {
try {
const date = new Date(value);
if (timeGranularity === 'hour') {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:00`;
if (showHours) {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
}
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
} catch {
@@ -957,21 +944,6 @@ export default function TransactionsPage() {
<PageSection
title="Buy Price Statistics"
icon={ShoppingCart}
headerRight={
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<select
value={timeGranularity}
onChange={(e) => setTimeGranularity(e.target.value as TimeGranularity)}
className="eve-input px-2 py-1 text-sm min-w-[100px]"
>
<option value="hour">1H</option>
<option value="day">1D</option>
<option value="3days">3D</option>
<option value="7days">7D</option>
</select>
</div>
}
>
<div className="p-4 h-80">
<ResponsiveContainer width="100%" height="100%">
@@ -984,8 +956,8 @@ export default function TransactionsPage() {
tickFormatter={(value) => {
try {
const date = new Date(value);
if (timeGranularity === 'hour') {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:00`;
if (showHours) {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
}
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
} catch {
@@ -1009,8 +981,8 @@ export default function TransactionsPage() {
labelFormatter={(label) => {
try {
const date = new Date(label);
if (timeGranularity === 'hour') {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:00`;
if (showHours) {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
}
return formatDateISO(label);
} catch {
@@ -1035,21 +1007,6 @@ export default function TransactionsPage() {
<PageSection
title="Sell Price Statistics"
icon={TrendingUp}
headerRight={
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<select
value={timeGranularity}
onChange={(e) => setTimeGranularity(e.target.value as TimeGranularity)}
className="eve-input px-2 py-1 text-sm min-w-[100px]"
>
<option value="hour">1H</option>
<option value="day">1D</option>
<option value="3days">3D</option>
<option value="7days">7D</option>
</select>
</div>
}
>
<div className="p-4 h-80">
<ResponsiveContainer width="100%" height="100%">
@@ -1062,8 +1019,8 @@ export default function TransactionsPage() {
tickFormatter={(value) => {
try {
const date = new Date(value);
if (timeGranularity === 'hour') {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:00`;
if (showHours) {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
}
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
} catch {
@@ -1087,8 +1044,8 @@ export default function TransactionsPage() {
labelFormatter={(label) => {
try {
const date = new Date(label);
if (timeGranularity === 'hour') {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:00`;
if (showHours) {
return `${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
}
return formatDateISO(label);
} catch {