Intial Version

This commit is contained in:
2025-12-03 18:00:10 -05:00
parent 43c4227da7
commit 0b86c88eb4
55 changed files with 8938 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers';
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
title: 'rmtPocketWatcher',
});
// Setup IPC handlers for WebSocket communication
setupIpcHandlers(mainWindow);
// Load the app
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'));
}
mainWindow.on('closed', () => {
cleanupIpcHandlers();
mainWindow = null;
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
app.on('before-quit', () => {
cleanupIpcHandlers();
});

View File

@@ -0,0 +1,73 @@
import { ipcMain, BrowserWindow } from 'electron';
import { WebSocketClient } from './websocket-client';
import { PriceIndex } from '../shared/types';
let wsClient: WebSocketClient | null = null;
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const wsUrl = process.env.WS_URL || 'ws://localhost:3000/ws';
// Initialize WebSocket client
wsClient = new WebSocketClient(wsUrl);
// Forward WebSocket events to renderer
wsClient.on('connected', () => {
mainWindow.webContents.send('ws:connected');
});
wsClient.on('disconnected', () => {
mainWindow.webContents.send('ws:disconnected');
});
wsClient.on('priceUpdate', (data: PriceIndex) => {
mainWindow.webContents.send('ws:priceUpdate', data);
});
wsClient.on('historyData', (data: any) => {
mainWindow.webContents.send('ws:historyData', data);
});
wsClient.on('error', (error: Error) => {
mainWindow.webContents.send('ws:error', error.message);
});
wsClient.on('maxReconnectAttemptsReached', () => {
mainWindow.webContents.send('ws:maxReconnectAttemptsReached');
});
// IPC handlers for renderer requests
ipcMain.handle('ws:connect', async () => {
wsClient?.connect();
return { success: true };
});
ipcMain.handle('ws:disconnect', async () => {
wsClient?.disconnect();
return { success: true };
});
ipcMain.handle('ws:getStatus', async () => {
return {
connected: wsClient?.isConnected() || false,
state: wsClient?.getConnectionState() || 'DISCONNECTED',
};
});
ipcMain.handle('ws:send', async (_event, message: any) => {
wsClient?.sendMessage(message);
return { success: true };
});
// Auto-connect on startup
wsClient.connect();
}
export function cleanupIpcHandlers(): void {
wsClient?.disconnect();
wsClient = null;
ipcMain.removeHandler('ws:connect');
ipcMain.removeHandler('ws:disconnect');
ipcMain.removeHandler('ws:getStatus');
ipcMain.removeHandler('ws:send');
}

View File

@@ -0,0 +1,145 @@
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import { PriceIndex, WebSocketMessage, HistoricalData } from '../shared/types';
export class WebSocketClient extends EventEmitter {
private ws: WebSocket | null = null;
private url: string;
private reconnectInterval: number = 5000;
private reconnectTimer: NodeJS.Timeout | null = null;
private isIntentionallyClosed: boolean = false;
private maxReconnectAttempts: number = 10;
private reconnectAttempts: number = 0;
constructor(url: string) {
super();
this.url = url;
}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) {
console.log('WebSocket already connected');
return;
}
this.isIntentionallyClosed = false;
console.log(`Connecting to WebSocket: ${this.url}`);
try {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
this.emit('connected');
this.sendMessage({ type: 'subscribe', data: { channel: 'price_updates' } });
});
this.ws.on('message', (data: WebSocket.Data) => {
try {
const message: WebSocketMessage = JSON.parse(data.toString());
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
});
this.ws.on('close', (code: number, reason: Buffer) => {
console.log(`WebSocket closed: ${code} - ${reason.toString()}`);
this.emit('disconnected');
if (!this.isIntentionallyClosed) {
this.scheduleReconnect();
}
});
this.ws.on('error', (error: Error) => {
console.error('WebSocket error:', error);
this.emit('error', error);
});
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
this.scheduleReconnect();
}
}
private handleMessage(message: WebSocketMessage): void {
switch (message.type) {
case 'price_update':
this.emit('priceUpdate', message.data as PriceIndex);
break;
case 'history_data':
this.emit('historyData', message.data as HistoricalData);
break;
case 'connection_status':
this.emit('status', message.data);
break;
case 'error':
this.emit('error', new Error(message.data));
break;
default:
console.warn('Unknown message type:', message.type);
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.isIntentionallyClosed) {
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
this.emit('maxReconnectAttemptsReached');
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.reconnectInterval * this.reconnectAttempts, 30000);
console.log(`Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
sendMessage(message: any): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected, cannot send message');
}
}
disconnect(): void {
this.isIntentionallyClosed = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
getConnectionState(): string {
if (!this.ws) return 'DISCONNECTED';
switch (this.ws.readyState) {
case WebSocket.CONNECTING: return 'CONNECTING';
case WebSocket.OPEN: return 'CONNECTED';
case WebSocket.CLOSING: return 'CLOSING';
case WebSocket.CLOSED: return 'DISCONNECTED';
default: return 'UNKNOWN';
}
}
}

View File

@@ -0,0 +1,34 @@
import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electron', {
ipcRenderer: {
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args),
on: (channel: string, func: (...args: any[]) => void) => {
ipcRenderer.on(channel, (_event: any, ...args: any[]) => func(...args));
},
once: (channel: string, func: (...args: any[]) => void) => {
ipcRenderer.once(channel, (_event: any, ...args: any[]) => func(...args));
},
removeAllListeners: (channel: string) => {
ipcRenderer.removeAllListeners(channel);
},
},
});
// Type definitions for TypeScript
export interface IElectronAPI {
ipcRenderer: {
invoke: (channel: string, ...args: any[]) => Promise<any>;
on: (channel: string, func: (...args: any[]) => void) => void;
once: (channel: string, func: (...args: any[]) => void) => void;
removeAllListeners: (channel: string) => void;
};
}
declare global {
interface Window {
electron: IElectronAPI;
}
}

View File

@@ -0,0 +1,303 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const TIME_RANGES = [
{ label: '6 Hours', value: '6h' },
{ label: '24 Hours', value: '24h' },
{ label: '3 Days', value: '3d' },
{ label: '7 Days', value: '7d' },
{ label: '1 Month', value: '1mo' },
{ label: 'YTD', value: 'ytd' },
];
const COLORS = [
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#a28fd0', '#f5a623',
'#50e3c2', '#ff6b9d', '#4a90e2', '#7ed321', '#d0021b', '#f8e71c'
];
export function App() {
const { status, latestPrice, historyData, requestHistory } = useWebSocket();
const [selectedRange, setSelectedRange] = useState('7d');
const [zoomLevel, setZoomLevel] = useState(1);
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (status.connected) {
requestHistory(selectedRange);
}
}, [status.connected, selectedRange, requestHistory]);
const chartData = useMemo(() => {
if (!historyData?.prices) return [];
// Group by timestamp and seller
const grouped = new Map<string, any>();
historyData.prices.forEach(price => {
const time = new Date(price.timestamp).getTime();
const key = time.toString();
if (!grouped.has(key)) {
grouped.set(key, { timestamp: time, time: new Date(price.timestamp).toLocaleString() });
}
const entry = grouped.get(key);
const sellerKey = `${price.sellerName}`;
entry[sellerKey] = price.pricePerMillion;
});
return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp);
}, [historyData]);
const sellers = useMemo(() => {
if (!historyData?.prices) return [];
const uniqueSellers = new Set(historyData.prices.map(p => p.sellerName));
return Array.from(uniqueSellers);
}, [historyData]);
const { minY, maxY } = useMemo(() => {
if (!historyData?.prices || historyData.prices.length === 0) {
return { minY: 0, maxY: 1 };
}
// Get all prices and sort them
const allPrices = historyData.prices.map(p => p.pricePerMillion).sort((a, b) => a - b);
// Find the 90th percentile to focus on competitive prices
const percentile90Index = Math.floor(allPrices.length * 0.90);
const percentile90 = allPrices[percentile90Index];
// Get the minimum price
const minPrice = allPrices[0];
// Base max Y on 90th percentile with padding
const baseMaxY = Math.max(percentile90 * 1.1, minPrice * 2);
// Apply zoom level (higher zoom = smaller range)
const zoomedMaxY = baseMaxY / zoomLevel;
console.log('Y-axis domain:', { minY: 0, maxY: zoomedMaxY, zoom: zoomLevel, base: baseMaxY });
return { minY: 0, maxY: zoomedMaxY };
}, [historyData, zoomLevel]);
useEffect(() => {
const chartContainer = chartContainerRef.current;
if (!chartContainer) {
console.log('Chart container ref not found');
return;
}
console.log('Setting up wheel event listener on chart container');
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
console.log('Wheel event:', e.deltaY);
// Zoom in when scrolling up (negative deltaY), zoom out when scrolling down
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
setZoomLevel(prev => {
const newZoom = prev * zoomDelta;
const clampedZoom = Math.max(0.1, Math.min(10, newZoom));
console.log('Zoom level:', prev, '->', clampedZoom);
return clampedZoom;
});
};
chartContainer.addEventListener('wheel', handleWheel, { passive: false });
return () => {
console.log('Removing wheel event listener');
chartContainer.removeEventListener('wheel', handleWheel);
};
}, []);
const handleRangeChange = (range: string) => {
setSelectedRange(range);
};
const resetZoom = () => {
setZoomLevel(1);
};
return (
<div style={{
padding: '20px',
fontFamily: 'system-ui',
backgroundColor: '#0a0e27',
color: '#fff',
minHeight: '100%'
}}>
<h1 style={{ color: '#50e3c2', marginBottom: '10px' }}>rmtPocketWatcher</h1>
<p style={{ color: '#888', fontSize: '14px', marginBottom: '20px' }}>Lambda Banking Conglomerate</p>
<div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>CONNECTION</div>
<div style={{ fontSize: '18px', fontWeight: 'bold', color: status.connected ? '#50e3c2' : '#ff6b9d' }}>
{status.state}
</div>
</div>
{latestPrice && (
<>
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>LOWEST PRICE</div>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#50e3c2' }}>
${latestPrice.lowestPrice.toFixed(4)}
</div>
<div style={{ fontSize: '12px', color: '#888' }}>per 1M AUEC</div>
</div>
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>SELLER</div>
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{latestPrice.sellerName}</div>
<div style={{ fontSize: '12px', color: '#888' }}>{latestPrice.platform}</div>
</div>
</>
)}
</div>
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 style={{ margin: 0 }}>Price History</h2>
<div style={{ display: 'flex', gap: '10px' }}>
{TIME_RANGES.map(range => (
<button
key={range.value}
onClick={() => handleRangeChange(range.value)}
style={{
padding: '8px 16px',
backgroundColor: selectedRange === range.value ? '#50e3c2' : '#2a2f4a',
color: selectedRange === range.value ? '#0a0e27' : '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: selectedRange === range.value ? 'bold' : 'normal',
}}
>
{range.label}
</button>
))}
</div>
</div>
{historyData && historyData.prices && historyData.prices.length > 0 ? (
chartData.length > 0 ? (
<div>
<div style={{ marginBottom: '10px', color: '#888', fontSize: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Scroll on chart to zoom. Extreme outliers may be clipped for better visibility.</span>
<button
onClick={resetZoom}
style={{
padding: '4px 12px',
backgroundColor: '#2a2f4a',
color: '#fff',
border: '1px solid #50e3c2',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px'
}}
>
Reset Zoom (×{zoomLevel.toFixed(1)})
</button>
</div>
<div
ref={chartContainerRef}
style={{
width: '100%',
height: '500px',
minHeight: '400px',
cursor: 'ns-resize',
border: '1px solid #2a2f4a'
}}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} key={`chart-${zoomLevel}`}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
<XAxis
dataKey="time"
stroke="#888"
tick={{ fill: '#888', fontSize: 10 }}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis
stroke="#888"
tick={{ fill: '#888', fontSize: 12 }}
label={{ value: 'USD per 1M AUEC', angle: -90, position: 'insideLeft', fill: '#888' }}
domain={[0, yAxisDomain[1]]}
allowDataOverflow={true}
/>
<Tooltip
contentStyle={{ backgroundColor: '#1a1f3a', border: '1px solid #2a2f4a', borderRadius: '4px' }}
labelStyle={{ color: '#fff' }}
/>
<Legend
wrapperStyle={{ color: '#fff', maxHeight: '100px', overflowY: 'auto' }}
iconType="line"
/>
{sellers.map((seller, index) => (
<Line
key={seller}
type="monotone"
dataKey={seller}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#ff6b9d' }}>
Data received but chart data is empty. Check console for details.
</div>
)
) : (
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
{status.connected ? 'Loading historical data...' : 'Connect to view historical data'}
</div>
)}
</div>
{latestPrice && (
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px' }}>
<h2>Current Listings ({latestPrice.allPrices.length})</h2>
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: '400px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, backgroundColor: '#1a1f3a', zIndex: 1 }}>
<tr style={{ borderBottom: '2px solid #2a2f4a' }}>
<th style={{ textAlign: 'left', padding: '12px', color: '#888', fontWeight: 'normal' }}>Platform</th>
<th style={{ textAlign: 'left', padding: '12px', color: '#888', fontWeight: 'normal' }}>Seller</th>
<th style={{ textAlign: 'right', padding: '12px', color: '#888', fontWeight: 'normal' }}>Price/1M AUEC</th>
</tr>
</thead>
<tbody>
{latestPrice.allPrices.map((price, index) => (
<tr key={price.id} style={{ borderBottom: '1px solid #2a2f4a' }}>
<td style={{ padding: '12px' }}>{price.platform}</td>
<td style={{ padding: '12px' }}>{price.sellerName}</td>
<td style={{ textAlign: 'right', padding: '12px', color: '#50e3c2', fontWeight: 'bold' }}>
${price.pricePerMillion.toFixed(4)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useState, useCallback } from 'react';
import { PriceIndex, HistoricalData } from '../../shared/types';
interface WebSocketStatus {
connected: boolean;
state: string;
error?: string;
}
export function useWebSocket() {
const [status, setStatus] = useState<WebSocketStatus>({
connected: false,
state: 'DISCONNECTED',
});
const [latestPrice, setLatestPrice] = useState<PriceIndex | null>(null);
const [historyData, setHistoryData] = useState<HistoricalData | null>(null);
useEffect(() => {
// Listen for WebSocket events from main process
const handleConnected = () => {
setStatus({ connected: true, state: 'CONNECTED' });
};
const handleDisconnected = () => {
setStatus({ connected: false, state: 'DISCONNECTED' });
};
const handlePriceUpdate = (data: PriceIndex) => {
setLatestPrice(data);
};
const handleHistoryData = (data: HistoricalData) => {
setHistoryData(data);
};
const handleError = (error: string) => {
setStatus((prev: WebSocketStatus) => ({ ...prev, error }));
};
const handleMaxReconnect = () => {
setStatus((prev: WebSocketStatus) => ({
...prev,
error: 'Maximum reconnection attempts reached. Please restart the application.'
}));
};
// Register IPC listeners
window.electron.ipcRenderer.on('ws:connected', handleConnected);
window.electron.ipcRenderer.on('ws:disconnected', handleDisconnected);
window.electron.ipcRenderer.on('ws:priceUpdate', handlePriceUpdate);
window.electron.ipcRenderer.on('ws:historyData', handleHistoryData);
window.electron.ipcRenderer.on('ws:error', handleError);
window.electron.ipcRenderer.on('ws:maxReconnectAttemptsReached', handleMaxReconnect);
// Get initial status
window.electron.ipcRenderer.invoke('ws:getStatus').then((initialStatus) => {
setStatus(initialStatus);
});
// Cleanup
return () => {
window.electron.ipcRenderer.removeAllListeners('ws:connected');
window.electron.ipcRenderer.removeAllListeners('ws:disconnected');
window.electron.ipcRenderer.removeAllListeners('ws:priceUpdate');
window.electron.ipcRenderer.removeAllListeners('ws:historyData');
window.electron.ipcRenderer.removeAllListeners('ws:error');
window.electron.ipcRenderer.removeAllListeners('ws:maxReconnectAttemptsReached');
};
}, []);
const connect = useCallback(async () => {
await window.electron.ipcRenderer.invoke('ws:connect');
}, []);
const disconnect = useCallback(async () => {
await window.electron.ipcRenderer.invoke('ws:disconnect');
}, []);
const sendMessage = useCallback(async (message: any) => {
await window.electron.ipcRenderer.invoke('ws:send', message);
}, []);
const requestHistory = useCallback(async (range: string) => {
await sendMessage({ type: 'get_history', data: { range } });
}, [sendMessage]);
return {
status,
latestPrice,
historyData,
connect,
disconnect,
sendMessage,
requestHistory,
};
}

View File

@@ -0,0 +1,8 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';
const container = document.getElementById('root');
if (!container) throw new Error('Root element not found');
const root = createRoot(container);
root.render(<App />);

View File

@@ -0,0 +1,3 @@
"use strict";
// Shared types between main and renderer processes
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,43 @@
// Shared types between main and renderer processes
export interface VendorPrice {
id: string;
platform: 'eldorado' | 'playerauctions';
sellerName: string;
pricePerMillion: number;
timestamp: Date;
url?: string;
}
export interface PriceIndex {
timestamp: Date;
lowestPrice: number;
platform: string;
sellerName: string;
allPrices: VendorPrice[];
}
export interface PriceAlert {
id: string;
type: 'below' | 'above';
threshold: number;
enabled: boolean;
}
export interface WebSocketMessage {
type: 'price_update' | 'connection_status' | 'error' | 'history_data' | 'get_history' | 'subscribe';
data: any;
}
export interface HistoricalData {
range: string;
from: Date;
to: Date;
prices: VendorPrice[];
}
export interface ConnectionStatus {
connected: boolean;
lastUpdate?: Date;
error?: string;
}