diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index 1299f17..7dde2ec 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -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({ 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(() => { + 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(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 ( +
{ + 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 ( -
+ +
{/* Header */}
@@ -222,30 +326,45 @@ export function DashboardLayout() {
- - {/* Navigation */} - + + + {/* Duration Picker - Only visible on transactions page */} + {location.pathname === '/transactions' && ( +
+
+
+
+ + Bucket Size: +
+ +
+
+
+ )} + {/* Main Content */}
@@ -298,6 +417,7 @@ export function DashboardLayout() {
+ ); } diff --git a/src/lib/clickhouse-client.ts b/src/lib/clickhouse-client.ts index 2ddd5af..6ebd7e4 100644 --- a/src/lib/clickhouse-client.ts +++ b/src/lib/clickhouse-client.ts @@ -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 { - // 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 { + // Use duration for bucket size, or fallback to day if not provided + const groupExpr = duration ? durationToClickHouseBucket(duration) : 'toDate(wj.date)'; return queryClickHouse( ` @@ -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 { - // 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 { + // Use duration for bucket size, or fallback to day if not provided + const groupExpr = duration ? durationToClickHouseBucket(duration) : 'toDate(wj.date)'; return queryClickHouse( ` diff --git a/src/lib/duration-utils.test.ts b/src/lib/duration-utils.test.ts new file mode 100644 index 0000000..1f18eb7 --- /dev/null +++ b/src/lib/duration-utils.test.ts @@ -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'); + }); +}); diff --git a/src/lib/duration-utils.ts b/src/lib/duration-utils.ts new file mode 100644 index 0000000..ba019a0 --- /dev/null +++ b/src/lib/duration-utils.ts @@ -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; +} diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index 38ed3d1..c3217ea 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -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('desc'); const [typeNames, setTypeNames] = useState>(new Map()); const [iconUrls, setIconUrls] = useState>(new Map()); - const [timeGranularity, setTimeGranularity] = useState('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({ - 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({ - 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({ - queryKey: ['transaction-heatmap'], + queryKey: ['transaction-heatmap', 30], queryFn: () => FetchTransactionHeatmap(30), refetchInterval: 120000, }); @@ -602,21 +604,6 @@ export default function TransactionsPage() { - - - - } >
@@ -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() { - - -
- } >
@@ -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() { - - -
- } >
@@ -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 {