Implement a bucket size setting for the charts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
`
|
||||
|
||||
146
src/lib/duration-utils.test.ts
Normal file
146
src/lib/duration-utils.test.ts
Normal 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
116
src/lib/duration-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user