diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..c5bcf0a --- /dev/null +++ b/.gitea/workflows/README.md @@ -0,0 +1,49 @@ +# Gitea Actions - Release Workflow + +This workflow automatically builds and releases rmtPocketWatcher for Windows and Linux when you push a version tag. + +## How to Trigger a Release + +1. Update version in `electron-app/package.json`: + ```bash + cd electron-app + npm version patch # or minor, or major + ``` + +2. Push the tag to Gitea: + ```bash + git push origin main + git push origin --tags + ``` + +3. The workflow will automatically: + - Build Windows installer (.exe) + - Build Linux AppImage and .deb package + - Create a GitHub/Gitea release + - Upload all binaries to the release + +## Requirements + +- Gitea Actions must be enabled on your repository +- Runners must be configured for `windows-latest` and `ubuntu-latest` +- Repository must have write permissions for releases + +## Manual Build + +To build locally without releasing: + +```bash +cd electron-app +npm run electron:build -- --win # Windows +npm run electron:build -- --linux # Linux +``` + +Outputs will be in `electron-app/release/` + +## Troubleshooting + +If the workflow fails: +- Check that Node.js 20 is available on runners +- Verify all dependencies install correctly +- Check Gitea Actions logs for specific errors +- Ensure GITHUB_TOKEN has proper permissions diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..6744607 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,86 @@ +name: Deploy Backend to Docker + +on: + push: + branches: + - main + paths: + - 'backend/**' + - 'docker-compose.yml' + - '.gitea/workflows/deploy.yml' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build backend image + working-directory: backend + run: docker build -t rmtpocketwatcher-backend:latest . + + - name: Save image to tar + run: docker save rmtpocketwatcher-backend:latest -o backend-image.tar + + - name: Deploy via SSH to Docker host + env: + DOCKER_HOST: ${{ secrets.DOCKER_HOST }} + DOCKER_SSH_KEY: ${{ secrets.DOCKER_SSH_KEY }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + run: | + # Setup SSH + mkdir -p ~/.ssh + echo "$DOCKER_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + + # Copy image to remote host + scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \ + backend-image.tar ${DOCKER_USER}@${DOCKER_HOST}:/tmp/ + + # Copy docker-compose to remote host + scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \ + docker-compose.yml ${DOCKER_USER}@${DOCKER_HOST}:/opt/rmtpocketwatcher/ + + # Load image and restart services + ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \ + ${DOCKER_USER}@${DOCKER_HOST} << 'EOF' + cd /opt/rmtpocketwatcher + docker load -i /tmp/backend-image.tar + docker-compose down backend + docker-compose up -d backend + rm /tmp/backend-image.tar + EOF + + deploy-portainer: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy via Portainer Webhook + env: + PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }} + run: | + curl -X POST "$PORTAINER_WEBHOOK_URL" + + - name: Deploy via Portainer API + env: + PORTAINER_URL: ${{ secrets.PORTAINER_URL }} + PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} + PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }} + run: | + # Pull latest from git and redeploy stack + curl -X PUT \ + "${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}/git/redeploy" \ + -H "X-API-Key: ${PORTAINER_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "pullImage": true, + "prune": true + }' diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..5c40f37 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,96 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' + +jobs: + build-windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install electron-app dependencies + working-directory: electron-app + run: npm ci + + - name: Build and package for Windows + working-directory: electron-app + run: npm run electron:build -- --win + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: electron-app/release/*.exe + retention-days: 7 + + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install electron-app dependencies + working-directory: electron-app + run: npm ci + + - name: Build and package for Linux + working-directory: electron-app + run: npm run electron:build -- --linux + + - name: Upload Linux AppImage + uses: actions/upload-artifact@v4 + with: + name: linux-appimage + path: electron-app/release/*.AppImage + retention-days: 7 + + - name: Upload Linux deb + uses: actions/upload-artifact@v4 + with: + name: linux-deb + path: electron-app/release/*.deb + retention-days: 7 + + create-release: + needs: [build-windows, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + + - name: Display structure of downloaded files + run: ls -R ./artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/windows-build/*.exe + artifacts/linux-appimage/*.AppImage + artifacts/linux-deb/*.deb + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/backend/src/api/routes/prices.ts b/backend/src/api/routes/prices.ts index d5fc745..59f7158 100644 --- a/backend/src/api/routes/prices.ts +++ b/backend/src/api/routes/prices.ts @@ -4,23 +4,44 @@ import { PriceRepository } from '../../database/repository'; const repository = new PriceRepository(); export const priceRoutes: FastifyPluginAsync = async (server) => { - // GET /api/prices/latest - Get all current listings + // GET /api/prices/latest - Get all current listings with lowest price server.get('/latest', async (request, reply) => { try { const prices = await repository.getLatestPrices(); + + if (prices.length === 0) { + return { + success: true, + data: { + timestamp: new Date(), + lowestPrice: 0, + platform: '', + sellerName: '', + allPrices: [] + } + }; + } + + const lowestPrice = prices.reduce((min: any, p: any) => + Number(p.usdPerMillion) < Number(min.usdPerMillion) ? p : min + ); + return { success: true, - data: prices.map((p: any) => ({ - id: p.id, - timestamp: p.timestamp, - vendor: p.vendor, - seller: p.sellerName, - usdPrice: p.usdPrice.toString(), - auecAmount: p.auecAmount.toString(), - usdPerMillion: p.usdPerMillion.toString(), - deliveryTime: p.deliveryTime, - url: p.url, - })), + data: { + timestamp: new Date(), + lowestPrice: Number(lowestPrice.usdPerMillion), + platform: lowestPrice.vendor, + sellerName: lowestPrice.sellerName, + allPrices: prices.map((p: any) => ({ + id: p.id, + platform: p.vendor, + sellerName: p.sellerName, + pricePerMillion: Number(p.usdPerMillion), + timestamp: p.timestamp, + url: p.url + })) + } }; } catch (error) { reply.code(500); diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..5b1413b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,147 @@ +# Deployment Guide + +Multiple deployment methods for rmtPocketWatcher backend. + +## Method 1: Portainer with Git Repository + +**Best for:** Teams using Portainer for container management + +### Setup in Portainer UI: + +1. Go to Stacks → Add Stack → Git Repository +2. Configure: + - **Name**: rmtpocketwatcher + - **Repository URL**: Your Git repo URL + - **Reference**: refs/heads/main + - **Compose path**: deploy/portainer-stack.yml +3. Add environment variables: + - `POSTGRES_PASSWORD`: your_secure_password + - `IMAGE_TAG`: latest +4. Deploy + +### Update via Webhook: + +1. In Portainer, enable webhook for the stack +2. Add webhook URL to Gitea secrets: `PORTAINER_WEBHOOK_URL` +3. Push to main branch triggers auto-deploy + +### Update via API Script: + +```bash +export PORTAINER_URL="http://your-portainer:9000" +export PORTAINER_API_KEY="your_api_key" +export POSTGRES_PASSWORD="your_password" +export GIT_REPO_URL="https://your-git-repo.git" + +chmod +x deploy/portainer-deploy.sh +./deploy/portainer-deploy.sh +``` + +## Method 2: Direct Docker via SSH + +**Best for:** Simple deployments to a single Docker host + +```bash +export DOCKER_HOST="your-server.com" +export DOCKER_USER="deploy" +export GIT_REPO="https://your-git-repo.git" + +chmod +x deploy/docker-deploy.sh +./deploy/docker-deploy.sh +``` + +For local deployment: +```bash +export DOCKER_HOST="localhost" +export GIT_REPO="https://your-git-repo.git" +./deploy/docker-deploy.sh +``` + +## Method 3: Gitea Actions (Automated) + +**Best for:** CI/CD pipeline automation + +### Setup: + +1. Add secrets to your Gitea repository: + - `DOCKER_HOST`: your-server.com + - `DOCKER_USER`: deploy + - `DOCKER_SSH_KEY`: Your SSH private key + +2. Push to main branch or modify backend files +3. Workflow automatically builds and deploys + +### Manual trigger: +- Go to Actions → Deploy Backend to Docker → Run workflow + +## Method 4: Portainer API via Gitea Actions + +**Best for:** Portainer users with CI/CD + +### Setup: + +1. Get Portainer API key: + - Portainer → User Settings → Access tokens → Add access token + +2. Add secrets to Gitea: + - `PORTAINER_URL`: http://your-portainer:9000 + - `PORTAINER_API_KEY`: your_api_key + - `PORTAINER_STACK_ID`: stack_id (from Portainer URL) + - `PORTAINER_WEBHOOK_URL`: webhook URL (optional) + +3. Manually trigger workflow or use webhook + +## Environment Variables + +Required for all methods: +- `POSTGRES_PASSWORD`: Database password + +Optional: +- `POSTGRES_USER`: Database user (default: rmtpw) +- `POSTGRES_DB`: Database name (default: rmtpocketwatcher) +- `BACKEND_PORT`: Backend port (default: 3000) +- `SCRAPE_INTERVAL`: Minutes between scrapes (default: 5) +- `SCRAPER_TIMEOUT`: Scraper timeout in ms (default: 30000) +- `SCRAPER_MAX_RETRIES`: Max retry attempts (default: 3) + +## Monitoring + +Check deployment status: +```bash +docker-compose ps +docker-compose logs -f backend +``` + +Via Portainer: +- Stacks → rmtpocketwatcher → Container logs + +## Rollback + +### Portainer: +1. Go to Stack → Editor +2. Change `IMAGE_TAG` to previous version +3. Update stack + +### Direct Docker: +```bash +cd /opt/rmtpocketwatcher +git checkout +docker-compose up -d --build backend +``` + +## Troubleshooting + +**Stack won't start:** +- Check environment variables are set +- Verify database connection +- Check logs: `docker-compose logs backend` + +**Portainer API fails:** +- Verify API key is valid +- Check endpoint ID matches your environment +- Ensure Portainer version supports Git deployment + +**SSH deployment fails:** +- Verify SSH key has correct permissions (600) +- Check user has Docker permissions +- Ensure Git is installed on remote host diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh new file mode 100644 index 0000000..e44d719 --- /dev/null +++ b/deploy/docker-deploy.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Direct Docker Deployment Script +# This script deploys rmtPocketWatcher directly to a Docker host via SSH + +set -e + +# Configuration +DOCKER_HOST="${DOCKER_HOST:-localhost}" +DOCKER_USER="${DOCKER_USER:-root}" +DEPLOY_PATH="${DEPLOY_PATH:-/opt/rmtpocketwatcher}" +GIT_REPO="${GIT_REPO}" +GIT_BRANCH="${GIT_BRANCH:-main}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${GREEN}Deploying rmtPocketWatcher to Docker host...${NC}" + +# Check if we're deploying locally or remotely +if [ "$DOCKER_HOST" = "localhost" ]; then + echo -e "${YELLOW}Deploying locally...${NC}" + + # Pull latest code + if [ -d "$DEPLOY_PATH" ]; then + cd "$DEPLOY_PATH" + git pull origin "$GIT_BRANCH" + else + git clone -b "$GIT_BRANCH" "$GIT_REPO" "$DEPLOY_PATH" + cd "$DEPLOY_PATH" + fi + + # Build and deploy + docker-compose down + docker-compose build backend + docker-compose up -d + + echo -e "${GREEN}Local deployment complete!${NC}" +else + echo -e "${YELLOW}Deploying to remote host: ${DOCKER_HOST}${NC}" + + # Deploy via SSH + ssh ${DOCKER_USER}@${DOCKER_HOST} << EOF + set -e + + # Pull or clone repository + if [ -d "${DEPLOY_PATH}" ]; then + cd "${DEPLOY_PATH}" + git pull origin ${GIT_BRANCH} + else + git clone -b ${GIT_BRANCH} ${GIT_REPO} ${DEPLOY_PATH} + cd "${DEPLOY_PATH}" + fi + + # Build and deploy + docker-compose down + docker-compose build backend + docker-compose up -d + + echo "Deployment complete!" +EOF + + echo -e "${GREEN}Remote deployment complete!${NC}" +fi + +# Show status +echo -e "${YELLOW}Checking service status...${NC}" +if [ "$DOCKER_HOST" = "localhost" ]; then + docker-compose ps +else + ssh ${DOCKER_USER}@${DOCKER_HOST} "cd ${DEPLOY_PATH} && docker-compose ps" +fi diff --git a/deploy/portainer-deploy.sh b/deploy/portainer-deploy.sh new file mode 100644 index 0000000..6fcee70 --- /dev/null +++ b/deploy/portainer-deploy.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Portainer Deployment Script +# This script deploys or updates the rmtPocketWatcher stack via Portainer API + +set -e + +# Configuration +PORTAINER_URL="${PORTAINER_URL:-http://localhost:9000}" +PORTAINER_API_KEY="${PORTAINER_API_KEY}" +STACK_NAME="${STACK_NAME:-rmtpocketwatcher}" +ENDPOINT_ID="${ENDPOINT_ID:-1}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Check required variables +if [ -z "$PORTAINER_API_KEY" ]; then + echo -e "${RED}Error: PORTAINER_API_KEY is not set${NC}" + echo "Usage: PORTAINER_API_KEY=your_key ./portainer-deploy.sh" + exit 1 +fi + +echo -e "${GREEN}Deploying rmtPocketWatcher to Portainer...${NC}" + +# Check if stack exists +STACK_ID=$(curl -s -X GET \ + "${PORTAINER_URL}/api/stacks" \ + -H "X-API-Key: ${PORTAINER_API_KEY}" \ + | jq -r ".[] | select(.Name==\"${STACK_NAME}\") | .Id") + +if [ -z "$STACK_ID" ] || [ "$STACK_ID" = "null" ]; then + echo -e "${YELLOW}Stack not found. Creating new stack...${NC}" + + # Create new stack from Git repository + curl -X POST \ + "${PORTAINER_URL}/api/stacks/create/standalone/repository" \ + -H "X-API-Key: ${PORTAINER_API_KEY}" \ + -H "Content-Type: application/json" \ + -d @- << EOF +{ + "name": "${STACK_NAME}", + "endpointId": ${ENDPOINT_ID}, + "repositoryURL": "${GIT_REPO_URL}", + "repositoryReferenceName": "refs/heads/main", + "composeFile": "deploy/portainer-stack.yml", + "repositoryAuthentication": false, + "env": [ + {"name": "POSTGRES_PASSWORD", "value": "${POSTGRES_PASSWORD}"}, + {"name": "IMAGE_TAG", "value": "latest"} + ] +} +EOF + + echo -e "${GREEN}Stack created successfully!${NC}" +else + echo -e "${YELLOW}Stack found (ID: ${STACK_ID}). Updating...${NC}" + + # Update existing stack via Git pull + curl -X PUT \ + "${PORTAINER_URL}/api/stacks/${STACK_ID}/git/redeploy" \ + -H "X-API-Key: ${PORTAINER_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "pullImage": true, + "prune": true + }' + + echo -e "${GREEN}Stack updated successfully!${NC}" +fi + +echo -e "${GREEN}Deployment complete!${NC}" +echo -e "Access your backend at: ${PORTAINER_URL%:*}:3000" diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml new file mode 100644 index 0000000..6a159ec --- /dev/null +++ b/deploy/portainer-stack.yml @@ -0,0 +1,52 @@ +version: '3.8' + +# Portainer Stack Configuration +# This file is used when deploying via Portainer's Git repository feature + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-rmtpw} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-rmtpocketwatcher} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - rmtpw-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rmtpw} -d ${POSTGRES_DB:-rmtpocketwatcher}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-lambdabanking/rmtpocketwatcher-backend}:${IMAGE_TAG:-latest} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-rmtpw}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-rmtpocketwatcher}?schema=public + PORT: ${BACKEND_PORT:-3000} + HOST: 0.0.0.0 + NODE_ENV: production + SCRAPE_INTERVAL_MINUTES: ${SCRAPE_INTERVAL:-5} + SCRAPER_HEADLESS: "true" + SCRAPER_TIMEOUT: ${SCRAPER_TIMEOUT:-30000} + SCRAPER_MAX_RETRIES: ${SCRAPER_MAX_RETRIES:-3} + ports: + - "${BACKEND_PORT:-3000}:3000" + networks: + - rmtpw-network + labels: + - "com.centurylinklabs.watchtower.enable=true" + +volumes: + postgres_data: + driver: local + +networks: + rmtpw-network: + driver: bridge diff --git a/electron-app/index.html b/electron-app/index.html index 866bf48..640f0e8 100644 --- a/electron-app/index.html +++ b/electron-app/index.html @@ -16,6 +16,7 @@ width: 100%; height: 100%; overflow: hidden; + background: #0a0e27; } #root { @@ -23,6 +24,8 @@ height: 100%; overflow-y: scroll; overflow-x: hidden; + border: 2px solid #50e3c2; + box-shadow: 0 0 20px rgba(80, 227, 194, 0.3); } /* Custom scrollbar */ diff --git a/electron-app/package.json b/electron-app/package.json index f558a11..b22fea9 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -10,15 +10,20 @@ "build": "npm run build:main && npm run build:renderer", "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron dist/main/index.js\"", "electron:build": "npm run build && electron-builder", + "publish": "npm run build && electron-builder --publish always", + "rebuild": "electron-rebuild", + "postinstall": "electron-rebuild", "test": "jest" }, "dependencies": { + "better-sqlite3": "^12.5.0", "electron": "^30.0.0", "electron-updater": "^6.1.0", "recharts": "^3.5.1", "ws": "^8.16.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.10.0", "@types/react": "^18.3.27", "@types/ws": "^8.5.10", @@ -26,6 +31,7 @@ "concurrently": "^8.2.0", "cross-env": "^10.1.0", "electron-builder": "^24.9.0", + "electron-rebuild": "^3.2.9", "react": "^19.2.1", "react-dom": "^19.2.1", "typescript": "^5.3.0", @@ -42,6 +48,10 @@ "dist/**/*", "package.json" ], + "publish": { + "provider": "generic", + "url": "${env.RELEASE_URL}" + }, "win": { "target": [ "nsis" @@ -54,8 +64,10 @@ }, "linux": { "target": [ - "AppImage" - ] + "AppImage", + "deb" + ], + "category": "Finance" } } } diff --git a/electron-app/src/main/database.ts b/electron-app/src/main/database.ts new file mode 100644 index 0000000..8c9df7b --- /dev/null +++ b/electron-app/src/main/database.ts @@ -0,0 +1,88 @@ +import Database from 'better-sqlite3'; +import { app } from 'electron'; +import path from 'path'; +import fs from 'fs'; + +export interface PriceAlert { + id: string; + auecAmount: number; + maxPrice: number; + enabled: boolean; +} + +let db: Database.Database | null = null; + +export function initDatabase() { + const userDataPath = app.getPath('userData'); + const dbPath = path.join(userDataPath, 'alerts.db'); + + // Ensure directory exists + if (!fs.existsSync(userDataPath)) { + fs.mkdirSync(userDataPath, { recursive: true }); + } + + db = new Database(dbPath); + + // Create alerts table + db.exec(` + CREATE TABLE IF NOT EXISTS alerts ( + id TEXT PRIMARY KEY, + auecAmount REAL NOT NULL, + maxPrice REAL NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 + ) + `); + + console.log('Database initialized at:', dbPath); +} + +export function getAllAlerts(): PriceAlert[] { + if (!db) throw new Error('Database not initialized'); + + const stmt = db.prepare('SELECT * FROM alerts'); + const rows = stmt.all() as any[]; + + return rows.map(row => ({ + id: row.id, + auecAmount: row.auecAmount, + maxPrice: row.maxPrice, + enabled: row.enabled === 1, + })); +} + +export function addAlert(alert: PriceAlert): void { + if (!db) throw new Error('Database not initialized'); + + const stmt = db.prepare(` + INSERT INTO alerts (id, auecAmount, maxPrice, enabled) + VALUES (?, ?, ?, ?) + `); + + stmt.run(alert.id, alert.auecAmount, alert.maxPrice, alert.enabled ? 1 : 0); +} + +export function updateAlert(alert: PriceAlert): void { + if (!db) throw new Error('Database not initialized'); + + const stmt = db.prepare(` + UPDATE alerts + SET auecAmount = ?, maxPrice = ?, enabled = ? + WHERE id = ? + `); + + stmt.run(alert.auecAmount, alert.maxPrice, alert.enabled ? 1 : 0, alert.id); +} + +export function deleteAlert(id: string): void { + if (!db) throw new Error('Database not initialized'); + + const stmt = db.prepare('DELETE FROM alerts WHERE id = ?'); + stmt.run(id); +} + +export function closeDatabase(): void { + if (db) { + db.close(); + db = null; + } +} diff --git a/electron-app/src/main/index.ts b/electron-app/src/main/index.ts index 8f0e1b8..159bfed 100644 --- a/electron-app/src/main/index.ts +++ b/electron-app/src/main/index.ts @@ -1,13 +1,19 @@ -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'; import * as path from 'path'; import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers'; +import { initDatabase, closeDatabase } from './database'; let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; function createWindow(): void { mainWindow = new BrowserWindow({ width: 1400, height: 900, + minWidth: 1000, + minHeight: 700, + frame: false, + backgroundColor: '#0a0e27', webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, @@ -34,12 +40,53 @@ function createWindow(): void { }); } -app.whenReady().then(createWindow); +function createTray(): void { + // Create a simple icon for the tray (16x16 cyan square) + const icon = nativeImage.createFromDataURL( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABNSURBVDiNY/z//z8DJYCJgUIwasCoAaMGjBowaMIAhv///zNQCpiYKASjBowaMGrAqAGDJgxGDRg1YNSAUQNGDRg0YTBqwKgBowYMmjAAALmyAwVYbMsAAAAASUVORK5CYII=' + ); + + tray = new Tray(icon); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Show rmtPocketWatcher', + click: () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + } + }, + { + label: 'Quit', + click: () => { + app.quit(); + } + } + ]); + + tray.setToolTip('rmtPocketWatcher'); + tray.setContextMenu(contextMenu); + + // Double-click to show window + tray.on('double-click', () => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + }); +} -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } +app.whenReady().then(() => { + initDatabase(); + createTray(); + createWindow(); +}); + +app.on('window-all-closed', (e: Event) => { + // Prevent app from quitting when window is closed (allow tray to keep it running) + e.preventDefault(); }); app.on('activate', () => { @@ -50,4 +97,5 @@ app.on('activate', () => { app.on('before-quit', () => { cleanupIpcHandlers(); + closeDatabase(); }); diff --git a/electron-app/src/main/ipc-handlers.ts b/electron-app/src/main/ipc-handlers.ts index a12663f..9940385 100644 --- a/electron-app/src/main/ipc-handlers.ts +++ b/electron-app/src/main/ipc-handlers.ts @@ -1,14 +1,17 @@ import { ipcMain, BrowserWindow } from 'electron'; import { WebSocketClient } from './websocket-client'; import { PriceIndex } from '../shared/types'; +import * as db from './database'; 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); + // Initialize WebSocket client only once + if (!wsClient) { + wsClient = new WebSocketClient(wsUrl); + } // Forward WebSocket events to renderer wsClient.on('connected', () => { @@ -35,6 +38,22 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { mainWindow.webContents.send('ws:maxReconnectAttemptsReached'); }); + // Remove existing handlers + try { ipcMain.removeHandler('ws:connect'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('ws:disconnect'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('ws:getStatus'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('ws:send'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('api:getLatestPrices'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('window:minimize'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('window:maximize'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('window:close'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('window:isMaximized'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('window:hideToTray'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('alerts:getAll'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('alerts:add'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('alerts:update'); } catch (e) { /* ignore */ } + try { ipcMain.removeHandler('alerts:delete'); } catch (e) { /* ignore */ } + // IPC handlers for renderer requests ipcMain.handle('ws:connect', async () => { wsClient?.connect(); @@ -58,16 +77,111 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void { return { success: true }; }); + // Fetch initial data from API + ipcMain.handle('api:getLatestPrices', async () => { + try { + const apiUrl = process.env.API_URL || 'http://localhost:3000'; + const response = await fetch(`${apiUrl}/api/prices/latest`); + const data = await response.json(); + return data; + } catch (error) { + console.error('Failed to fetch latest prices:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + // Window control handlers + ipcMain.handle('window:minimize', () => { + mainWindow.minimize(); + }); + + ipcMain.handle('window:maximize', () => { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + }); + + ipcMain.handle('window:close', () => { + mainWindow.close(); + }); + + ipcMain.handle('window:isMaximized', () => { + return mainWindow.isMaximized(); + }); + + ipcMain.handle('window:hideToTray', () => { + mainWindow.hide(); + }); + + // Alert management handlers + ipcMain.handle('alerts:getAll', async () => { + try { + return db.getAllAlerts(); + } catch (error) { + console.error('Failed to get alerts:', error); + return []; + } + }); + + ipcMain.handle('alerts:add', async (_event, alert: db.PriceAlert) => { + try { + db.addAlert(alert); + return { success: true }; + } catch (error) { + console.error('Failed to add alert:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + ipcMain.handle('alerts:update', async (_event, alert: db.PriceAlert) => { + try { + db.updateAlert(alert); + return { success: true }; + } catch (error) { + console.error('Failed to update alert:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + ipcMain.handle('alerts:delete', async (_event, id: string) => { + try { + db.deleteAlert(id); + return { success: true }; + } catch (error) { + console.error('Failed to delete alert:', error); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + // Auto-connect on startup wsClient.connect(); } export function cleanupIpcHandlers(): void { - wsClient?.disconnect(); - wsClient = null; + if (wsClient) { + wsClient.disconnect(); + wsClient = null; + } - ipcMain.removeHandler('ws:connect'); - ipcMain.removeHandler('ws:disconnect'); - ipcMain.removeHandler('ws:getStatus'); - ipcMain.removeHandler('ws:send'); + // Safely remove handlers (won't throw if they don't exist) + try { + ipcMain.removeHandler('ws:connect'); + ipcMain.removeHandler('ws:disconnect'); + ipcMain.removeHandler('ws:getStatus'); + ipcMain.removeHandler('ws:send'); + ipcMain.removeHandler('api:getLatestPrices'); + ipcMain.removeHandler('window:minimize'); + ipcMain.removeHandler('window:maximize'); + ipcMain.removeHandler('window:close'); + ipcMain.removeHandler('window:isMaximized'); + ipcMain.removeHandler('window:hideToTray'); + ipcMain.removeHandler('alerts:getAll'); + ipcMain.removeHandler('alerts:add'); + ipcMain.removeHandler('alerts:update'); + ipcMain.removeHandler('alerts:delete'); + } catch (error) { + // Handlers may not exist, ignore + } } diff --git a/electron-app/src/renderer/App.tsx b/electron-app/src/renderer/App.tsx index 666e514..02738b4 100644 --- a/electron-app/src/renderer/App.tsx +++ b/electron-app/src/renderer/App.tsx @@ -1,13 +1,14 @@ import { useState, useEffect, useMemo, useRef } from 'react'; import { useWebSocket } from './hooks/useWebSocket'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { TitleBar } from './components/TitleBar'; 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: '6H', value: '6h' }, + { label: '24H', value: '24h' }, + { label: '3D', value: '3d' }, + { label: '7D', value: '7d' }, + { label: '1M', value: '1mo' }, { label: 'YTD', value: 'ytd' }, ]; @@ -16,19 +17,146 @@ const COLORS = [ '#50e3c2', '#ff6b9d', '#4a90e2', '#7ed321', '#d0021b', '#f8e71c' ]; +interface ZoomState { + xStart: number; + xEnd: number; + yMin: number; + yMax: number; +} + +interface PriceAlert { + id: string; + auecAmount: number; + maxPrice: number; + enabled: boolean; +} + +interface AlertNotification { + id: string; + message: string; + sellerName: string; + platform: string; + price: number; +} + export function App() { - const { status, latestPrice, historyData, requestHistory } = useWebSocket(); + const { status, latestPrice, historyData, requestHistory, fetchInitialData } = useWebSocket(); const [selectedRange, setSelectedRange] = useState('7d'); - const [zoomLevel, setZoomLevel] = useState(1); + const [zoomState, setZoomState] = useState(null); const chartContainerRef = useRef(null); + + // Price Alert State + const [alerts, setAlerts] = useState([]); + const [newAlertAuec, setNewAlertAuec] = useState(''); + const [newAlertPrice, setNewAlertPrice] = useState(''); + const [alertNotification, setAlertNotification] = useState(null); + const alertAudioRef = useRef(null); + // Load alerts from database on mount useEffect(() => { - if (status.connected) { - requestHistory(selectedRange); - } - }, [status.connected, selectedRange, requestHistory]); + const loadAlerts = async () => { + try { + const savedAlerts = await window.electron.ipcRenderer.invoke('alerts:getAll'); + setAlerts(savedAlerts); + } catch (error) { + console.error('Failed to load alerts:', error); + } + }; + loadAlerts(); + }, []); - const chartData = useMemo(() => { + // Fetch initial data on mount + useEffect(() => { + fetchInitialData(); + }, [fetchInitialData]); + + // Request historical data when range changes + useEffect(() => { + requestHistory(selectedRange); + }, [selectedRange, requestHistory]); + + // Check for price alerts + useEffect(() => { + if (!latestPrice || alerts.length === 0) return; + + alerts.forEach(alert => { + if (!alert.enabled) return; + + // Check if any seller meets the alert criteria + const matchingSeller = latestPrice.allPrices.find(price => { + const totalPrice = (alert.auecAmount / 1000000) * price.pricePerMillion; + return totalPrice <= alert.maxPrice; + }); + + if (matchingSeller) { + const totalPrice = (alert.auecAmount / 1000000) * matchingSeller.pricePerMillion; + + // Show notification + const notification = { + id: Date.now().toString(), + message: `${alert.auecAmount.toLocaleString()} AUEC available for $${totalPrice.toFixed(2)}`, + sellerName: matchingSeller.sellerName, + platform: matchingSeller.platform, + price: totalPrice, + }; + + setAlertNotification(notification); + + // Play alert sound + if (alertAudioRef.current) { + alertAudioRef.current.play().catch(e => console.error('Audio play failed:', e)); + } + + // OS notification + if (window.Notification && Notification.permission === 'granted') { + new Notification('Price Alert - rmtPocketWatcher', { + body: `${notification.message}\nSeller: ${matchingSeller.sellerName} (${matchingSeller.platform})`, + icon: 'data:image/svg+xml,💰' + }); + } + + // Disable alert after triggering + const disabledAlert = { ...alert, enabled: false }; + window.electron.ipcRenderer.invoke('alerts:update', disabledAlert).catch(err => + console.error('Failed to update alert in DB:', err) + ); + setAlerts(prev => prev.map(a => + a.id === alert.id ? disabledAlert : a + )); + } + }); + }, [latestPrice, alerts]); + + // Request notification permission on mount + useEffect(() => { + if (window.Notification && Notification.permission === 'default') { + Notification.requestPermission(); + } + }, []); + + // Helper function to format dates elegantly + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + // If within last 24 hours, show time + if (diffHours < 24) { + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + } + // If within last 7 days, show day and time + else if (diffDays < 7) { + return date.toLocaleDateString('en-US', { weekday: 'short', hour: '2-digit', minute: '2-digit' }); + } + // Otherwise show date + else { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + }; + + const fullChartData = useMemo(() => { if (!historyData?.prices) return []; // Group by timestamp and seller @@ -39,7 +167,11 @@ export function App() { const key = time.toString(); if (!grouped.has(key)) { - grouped.set(key, { timestamp: time, time: new Date(price.timestamp).toLocaleString() }); + grouped.set(key, { + timestamp: time, + time: formatDate(time), + fullTime: new Date(price.timestamp).toLocaleString() + }); } const entry = grouped.get(key); @@ -50,15 +182,41 @@ export function App() { return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp); }, [historyData]); + const chartData = useMemo(() => { + if (!fullChartData.length) return []; + if (!zoomState) return fullChartData; + + // Find data points within the zoom range, plus one point on each side for continuity + const filtered = fullChartData.filter(d => + d.timestamp >= zoomState.xStart && d.timestamp <= zoomState.xEnd + ); + + // If we have filtered data, return it + if (filtered.length > 0) return filtered; + + // If no exact matches, find the closest points to show something + // This prevents empty chart when zoomed between data points + const closestBefore = fullChartData.filter(d => d.timestamp < zoomState.xStart).slice(-2); + const closestAfter = fullChartData.filter(d => d.timestamp > zoomState.xEnd).slice(0, 2); + + return [...closestBefore, ...closestAfter]; + }, [fullChartData, zoomState]); + 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(() => { + const yAxisDomain = useMemo(() => { + // Use zoom state if available + if (zoomState) { + return [zoomState.yMin, zoomState.yMax]; + } + + // Default calculation if (!historyData?.prices || historyData.prices.length === 0) { - return { minY: 0, maxY: 1 }; + return [0, 1]; } // Get all prices and sort them @@ -74,66 +232,225 @@ export function App() { // 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]); + return [0, baseMaxY]; + }, [historyData, zoomState]); 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'); + if (!chartContainer || !fullChartData.length) return; const handleWheel = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); - console.log('Wheel event:', e.deltaY); + const isZoomIn = e.deltaY < 0; + const zoomFactor = isZoomIn ? 0.8 : 1.25; // Zoom in = smaller range, zoom out = larger range - // Zoom in when scrolling up (negative deltaY), zoom out when scrolling down - const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; + // Get mouse position relative to chart + const rect = chartContainer.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; - setZoomLevel(prev => { - const newZoom = prev * zoomDelta; - const clampedZoom = Math.max(0.1, Math.min(10, newZoom)); - console.log('Zoom level:', prev, '->', clampedZoom); - return clampedZoom; + // Calculate mouse X position as percentage of chart area + const xPercent = mouseX / rect.width; + + // Get current ranges + const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp; + const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp; + const currentYMax = zoomState?.yMax ?? yAxisDomain[1]; + + // Calculate current X range + const xRange = currentXEnd - currentXStart; + + // Calculate new X range centered on mouse position + let newXRange = xRange * zoomFactor; + + // Prevent zooming in too much - ensure minimum time range + // Calculate minimum based on data density + const fullDataRange = fullChartData[fullChartData.length - 1].timestamp - fullChartData[0].timestamp; + const minTimeRange = fullDataRange / 1000; // Allow up to 1000x zoom + + if (isZoomIn && newXRange < minTimeRange) { + newXRange = minTimeRange; + } + + // Calculate mouse position in X data coordinates + const mouseXData = currentXStart + xRange * xPercent; + + // Calculate new X bounds centered on mouse position + let newXStart = mouseXData - newXRange * xPercent; + let newXEnd = mouseXData + newXRange * (1 - xPercent); + + // Constrain X to data bounds + const dataXStart = fullChartData[0].timestamp; + const dataXEnd = fullChartData[fullChartData.length - 1].timestamp; + + if (newXStart < dataXStart) { + newXEnd += dataXStart - newXStart; + newXStart = dataXStart; + } + if (newXEnd > dataXEnd) { + newXStart -= newXEnd - dataXEnd; + newXEnd = dataXEnd; + } + newXStart = Math.max(dataXStart, newXStart); + newXEnd = Math.min(dataXEnd, newXEnd); + + // Y-axis: always zoom from 0, just adjust the max + const newYMax = currentYMax * zoomFactor; + + setZoomState({ + xStart: newXStart, + xEnd: newXEnd, + yMin: 0, + yMax: newYMax, }); }; chartContainer.addEventListener('wheel', handleWheel, { passive: false }); return () => { - console.log('Removing wheel event listener'); chartContainer.removeEventListener('wheel', handleWheel); }; - }, []); + }, [fullChartData, zoomState, yAxisDomain]); const handleRangeChange = (range: string) => { setSelectedRange(range); + setZoomState(null); }; const resetZoom = () => { - setZoomLevel(1); + setZoomState(null); + }; + + const addAlert = async () => { + const auec = parseFloat(newAlertAuec); + const price = parseFloat(newAlertPrice); + + if (isNaN(auec) || isNaN(price) || auec <= 0 || price <= 0) { + alert('Please enter valid amounts'); + return; + } + + const newAlert: PriceAlert = { + id: Date.now().toString(), + auecAmount: auec, + maxPrice: price, + enabled: true, + }; + + try { + await window.electron.ipcRenderer.invoke('alerts:add', newAlert); + setAlerts(prev => [...prev, newAlert]); + setNewAlertAuec(''); + setNewAlertPrice(''); + } catch (error) { + console.error('Failed to add alert:', error); + alert('Failed to add alert'); + } + }; + + const toggleAlert = async (id: string) => { + const alert = alerts.find(a => a.id === id); + if (!alert) return; + + const updatedAlert = { ...alert, enabled: !alert.enabled }; + + try { + await window.electron.ipcRenderer.invoke('alerts:update', updatedAlert); + setAlerts(prev => prev.map(a => + a.id === id ? updatedAlert : a + )); + } catch (error) { + console.error('Failed to toggle alert:', error); + } + }; + + const deleteAlert = async (id: string) => { + try { + await window.electron.ipcRenderer.invoke('alerts:delete', id); + setAlerts(prev => prev.filter(a => a.id !== id)); + } catch (error) { + console.error('Failed to delete alert:', error); + } + }; + + const dismissNotification = () => { + setAlertNotification(null); + }; + + const handleZoomIn = () => { + if (!fullChartData.length) return; + + const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp; + const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp; + const currentYMax = zoomState?.yMax ?? yAxisDomain[1]; + + const xRange = currentXEnd - currentXStart; + const newXRange = xRange * 0.8; + + const xCenter = (currentXStart + currentXEnd) / 2; + + // Y-axis: zoom from 0 + const newYMax = currentYMax * 0.8; + + setZoomState({ + xStart: xCenter - newXRange / 2, + xEnd: xCenter + newXRange / 2, + yMin: 0, + yMax: newYMax, + }); + }; + + const handleZoomOut = () => { + if (!fullChartData.length) return; + + const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp; + const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp; + const currentYMax = zoomState?.yMax ?? yAxisDomain[1]; + + const xRange = currentXEnd - currentXStart; + const newXRange = xRange * 1.25; + + const xCenter = (currentXStart + currentXEnd) / 2; + + const dataXStart = fullChartData[0].timestamp; + const dataXEnd = fullChartData[fullChartData.length - 1].timestamp; + + const newXStart = Math.max(dataXStart, xCenter - newXRange / 2); + const newXEnd = Math.min(dataXEnd, xCenter + newXRange / 2); + + // Y-axis: zoom from 0 + const newYMax = currentYMax * 1.25; + + setZoomState({ + xStart: newXStart, + xEnd: newXEnd, + yMin: 0, + yMax: newYMax, + }); + }; + + const getZoomLevel = () => { + if (!zoomState || !fullChartData.length) return 1; + + const fullXRange = fullChartData[fullChartData.length - 1].timestamp - fullChartData[0].timestamp; + const currentXRange = zoomState.xEnd - zoomState.xStart; + + return fullXRange / currentXRange; }; return (
-

rmtPocketWatcher

-

Lambda Banking Conglomerate

+ + +
@@ -163,21 +480,141 @@ export function App() {
-
+

Price Alerts

+ +
+ setNewAlertAuec(e.target.value)} + style={{ + padding: '10px', + backgroundColor: '#2a2f4a', + color: '#fff', + border: '1px solid #50e3c2', + borderRadius: '4px', + fontSize: '14px', + flex: '1', + minWidth: '150px', + }} + /> + setNewAlertPrice(e.target.value)} + style={{ + padding: '10px', + backgroundColor: '#2a2f4a', + color: '#fff', + border: '1px solid #50e3c2', + borderRadius: '4px', + fontSize: '14px', + flex: '1', + minWidth: '150px', + }} + /> + +
+ + {alerts.length > 0 ? ( +
+ {alerts.map(alert => ( +
+
+
+ {alert.auecAmount.toLocaleString()} AUEC for ${alert.maxPrice.toFixed(2)} +
+
+ ${(alert.maxPrice / (alert.auecAmount / 1000000)).toFixed(4)} per 1M AUEC +
+
+
+ + +
+
+ ))} +
+ ) : ( +
+ No alerts set. Add an alert to get notified when prices meet your criteria. +
+ )} +
+ +
+

Price History

-
+
{TIME_RANGES.map(range => (
- - {historyData && historyData.prices && historyData.prices.length > 0 ? ( chartData.length > 0 ? (
-
- Scroll on chart to zoom. Extreme outliers may be clipped for better visibility. - +
+
+ + + +
+ +
+ Scroll on chart to zoom into area • Outliers may be clipped +
+
- - - - - - - {sellers.map((seller, index) => ( - + + - ))} - - + + { + if (payload && payload.length > 0 && payload[0].payload.fullTime) { + return payload[0].payload.fullTime; + } + return label; + }} + formatter={(value: any, name: string) => [`$${Number(value).toFixed(4)}`, name]} + wrapperStyle={{ zIndex: 1000 }} + /> + + {sellers.map((seller) => ( + + ))} + +
) : ( @@ -273,8 +769,14 @@ export function App() { {latestPrice && (
-

Current Listings ({latestPrice.allPrices.length})

-
+

Current Listings ({latestPrice.allPrices.length})

+
@@ -284,7 +786,7 @@ export function App() { - {latestPrice.allPrices.map((price, index) => ( + {latestPrice.allPrices.map((price) => ( @@ -298,6 +800,57 @@ export function App() { )} + + + {/* Alert Notification Popup */} + {alertNotification && ( +
+
+

🎯 Price Alert!

+ +
+
+ {alertNotification.message} +
+
+ Seller: {alertNotification.sellerName} +
+
+ Platform: {alertNotification.platform} +
+
+ )} + + {/* Alert Audio */} +
{price.platform} {price.sellerName}