TRON Parity Ladder — price parity & market cap flip dashboard

This commit is contained in:
gdegelas 2026-06-01 09:46:15 +00:00
commit c690f01dd2
28 changed files with 6553 additions and 0 deletions

47
.dockerignore Normal file
View File

@ -0,0 +1,47 @@
# ============================================
# Docker build context exclusions
# ============================================
# Dependencies & build cache
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Previous builds
dist
# Git & editor
.git
.gitignore
.vscode
.idea
*.swp
*.swo
.DS_Store
# Docker (nested builds not needed)
docker-compose*.yml
Dockerfile*
.dockerignore
# Docs / misc
README.md
LICENSE
CHANGELOG.md
*.md
# Tests & CI
.github
.gitlab-ci.yml
tests
coverage
.nyc_output
cypress
playwright
# Local env
.env
.env.*
!.env.example

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
# ============================================
# TRON Parity Ladder — Environment Configuration
# ============================================
# Copy this file to .env and customize:
# cp .env.example .env
# ============================================
# Port the container exposes on the host (default: 8080)
PORT=8080
# Optional: override the timezone inside the container
TZ=UTC
# Optional: image tag override (set via docker-compose build --build-arg)
# IMAGE_TAG=latest

198
DOCKER.md Normal file
View File

@ -0,0 +1,198 @@
# 🐳 Docker Deployment — TRON Parity Ladder
Production-grade containerization for the TRON Parity Ladder dashboard. Multi-stage build produces a minimal (~25 MB) Alpine Nginx image that serves the static bundle with gzip, caching headers, and SPA routing.
---
## 📁 What's included
| File | Purpose |
|------|---------|
| `Dockerfile` | Multi-stage build: Node 20 → Nginx 1.27 Alpine |
| `docker-compose.yml` | Service definition, healthcheck, resource limits |
| `docker/nginx.conf` | Production Nginx config (gzip, CSP, SPA routing, caching) |
| `.dockerignore` | Keeps build context lean (~500 KB) |
| `.env.example` | Environment variable reference |
| `Makefile` | Convenience shortcuts |
---
## 🚀 Quick start
```bash
# 1. Build & start in background
docker compose up --build -d
# 2. Visit the dashboard
open http://localhost:8080
# 3. Check health
curl http://localhost:8080/health
```
Or use the Makefile shortcuts:
```bash
make up # build & start
make logs # tail logs
make restart # restart container
make down # stop container
make clean # stop + remove image
make health # query /health
make status # resource usage
```
---
## ⚙️ Configuration
Copy the example environment file and customize:
```bash
cp .env.example .env
```
Available variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Host port to expose the dashboard on |
| `TZ` | `UTC` | Container timezone |
Example — run on port `3000`:
```bash
PORT=3000 docker compose up --build -d
```
---
## 🏗️ Architecture
```
┌──────────────────────────────────────┐
│ Docker Build (multi-stage) │
│ │
│ Stage 1: node:20-alpine │
│ └── npm ci → npm run build │
│ └── outputs /app/dist │
│ │
│ Stage 2: nginx:1.27-alpine │
│ └── COPY dist → /usr/share/... │
│ └── custom nginx.conf │
│ └── runs as nginx (non-root) │
│ └── serves on :80 │
└──────────────────────────────────────┘
```
---
## 🔒 Security features
- **Non-root execution** — container runs as `nginx` user
- **Security headers** — CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- **CSP whitelists** — only allows CoinGecko + Frankfurter APIs for outbound fetch
- **Hidden dot-files**`.env`, `.git`, etc. blocked from HTTP access
- **Resource limits** — max 1 CPU / 256 MB memory
- **Log rotation** — 3 × 10 MB files per container
---
## 🏭 Production deployment
### Behind a reverse proxy (Caddy / Traefik / Nginx)
```yaml
# docker-compose.prod.yml
services:
web:
labels:
- "traefik.http.routers.parity.rule=Host(`parity.example.com`)"
- "traefik.http.routers.parity.tls=true"
```
### Kubernetes
Generate a deployment spec:
```bash
docker compose convert --format kubernetes
# or use kompose:
kompose convert -f docker-compose.yml
```
### CI/CD
```bash
# Build & tag for registry
docker build -t registry.example.com/tron-parity-ladder:${GIT_SHA} .
docker push registry.example.com/tron-parity-ladder:${GIT_SHA}
```
---
## 🩺 Health & monitoring
```bash
# Health endpoint
curl http://localhost:8080/health # → "OK"
# Container health status
docker inspect --format='{{.State.Health.Status}}' tron-parity-ladder
# Live resource stats
docker stats tron-parity-ladder
```
---
## 🧹 Troubleshooting
| Symptom | Fix |
|---------|-----|
| Port already in use | Change `PORT` in `.env` |
| SPA routes 404 | Verify `try_files $uri /index.html` in `nginx.conf` |
| API calls blocked | Check CSP header in `docker/nginx.conf` — add origins to `connect-src` |
| Build slow | Delete stale layers: `docker system prune -a` |
| Container unhealthy | `docker compose logs web` — check nginx error log |
---
## 📡 Live Data Architecture
The SPA pulls from three public APIs with a cascading fallback:
| Priority | Source | Endpoint | Used for |
|----------|--------|----------|----------|
| **1 (primary)** | **TronScan** | `apilist.tronscan.org/api/token_trc20` | On-chain TRON token prices, supplies, market caps |
| 2 (fallback) | CoinGecko | `api.coingecko.com/v3/coins/markets` | Cross-exchange aggregated prices when TronScan is rate-limited |
| 3 (forex) | Frankfurter | `api.frankfurter.dev/v1/latest` | ECB-sourced fiat exchange rates (USD base) |
Every successful fetch is **recorded to the browser's IndexedDB** (`tron-parity-history` database), enabling time-series and synthetic-pair charts (e.g. BTT/JST, APENFT/WIN) locally on each user's device.
The header badge reports which source actually supplied the data:
- 🟢 **TRONSCAN** — full on-chain data
- 🔵 **COINGECKO** — TronScan offline, fallback active
- 🟡 **MIXED** — partial TronScan, partial CoinGecko fill
- ⚫ **BASELINE** — both APIs unreachable, using bundled constants
### Path to a persistent backend
IndexedDB is **per-browser** — data doesn't sync across users or survive a cache clear. For a shared, server-side history that can power real charts, add a companion service:
```
┌─────────────┐ ┌──────────────────┐ ┌───────────┐
│ Node Worker │────▶│ Postgres + REST │◀────│ SPA │
│ (cron 60s) │ │ /api/history │ │ (browser)│
│ → TronScan │ │ /api/synthetic │ └───────────┘
└─────────────┘ └──────────────────┘
```
This worker lives in the same `docker-compose.yml` — swap the SPA's `dataService.ts` to point at `/api/*` instead of the public endpoints, and IndexedDB becomes a client-side cache rather than the source of truth.
## 📊 Image size
| Stage | Size |
|-------|------|
| `node:20-alpine` (builder) | ~200 MB (discarded) |
| `nginx:1.27-alpine` (final) | **~25 MB** ✅ |

46
Dockerfile Normal file
View File

@ -0,0 +1,46 @@
# ============================================
# Stage 1: Build the static bundle with Vite
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# Copy manifests first to leverage Docker layer cache
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund
# Copy the rest of the source
COPY . .
# Build the production bundle (outputs to /app/dist)
RUN npm run build
# ============================================
# Stage 2: Serve with Nginx (production-grade)
# ============================================
FROM nginx:1.27-alpine AS production
# Remove default nginx config and static assets
RUN rm -rf /etc/nginx/conf.d/default.conf /usr/share/nginx/html/*
# Install custom nginx config
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
# Copy built bundle from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Security: run as non-root
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
EXPOSE 80
# Healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

47
Makefile Normal file
View File

@ -0,0 +1,47 @@
# ============================================
# TRON Parity Ladder — Docker Makefile
# ============================================
# Quick shortcuts for Docker-based operations
# ============================================
IMAGE_NAME := tron-parity-ladder
IMAGE_TAG := latest
.PHONY: help build up down logs restart clean shell health
help: ## Show this help
@echo "TRON Parity Ladder — Docker Commands"
@echo ""
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
build: ## Build the Docker image (multi-stage)
docker compose build --no-cache
up: ## Build (if needed) and start the container in background
docker compose up --build -d
@echo ""
@echo "✅ Dashboard running at: http://localhost:$${PORT:-8080}"
@echo " Health check: http://localhost:$${PORT:-8080}/health"
down: ## Stop and remove the container
docker compose down
logs: ## Tail container logs (Ctrl+C to stop)
docker compose logs -f
restart: down up ## Restart the container
clean: down ## Stop container and remove image + volumes
docker rmi $(IMAGE_NAME):$(IMAGE_TAG) 2>/dev/null || true
docker system prune -f
shell: ## Open a shell inside the running container
docker compose exec web sh
health: ## Query the health endpoint
@curl -s http://localhost:$${PORT:-8080}/health && echo
status: ## Show container status and resource usage
docker compose ps
@echo ""
docker stats --no-stream $(IMAGE_NAME) 2>/dev/null || true

17
deploy-tron-degelas.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
set -euo pipefail
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
PROXY_DIR="/root/fullstack_degelas"
echo "Building and starting TRON stack..."
cd "$APP_DIR"
docker compose up --build -d
echo "Reloading nginx reverse proxy..."
cd "$PROXY_DIR"
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
echo "Done. Verify with:"
echo " curl -I https://tron.degelas.be"

56
docker-compose.yml Normal file
View File

@ -0,0 +1,56 @@
# ============================================
# TRON Parity Ladder — Production Docker Compose
# ============================================
# Usage:
# docker compose up --build -d
# docker compose logs -f
# docker compose down
# ============================================
version: "3.9"
services:
# ------------------------------------------
# Web — Serves the TRON Parity Ladder SPA
# ------------------------------------------
web:
build:
context: .
dockerfile: Dockerfile
target: production
image: tron-parity-ladder:latest
container_name: tron-parity-ladder
restart: unless-stopped
expose:
- "80"
environment:
- NODE_ENV=production
- TZ=UTC
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- parity-net
- fullstack_degelas_proxy
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
reservations:
cpus: "0.25"
memory: 64M
networks:
parity-net:
driver: bridge
fullstack_degelas_proxy:
external: true

73
docker/nginx.conf Normal file
View File

@ -0,0 +1,73 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# === Security headers ===
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; connect-src 'self' https://apilist.tronscan.org https://api.coingecko.com https://api.frankfurter.dev;" always;
# === Compression ===
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# === Static asset caching (1 year hashed filenames) ===
location ~* \.(?:css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# === Media & font caching (1 week) ===
location ~* \.(?:png|jpg|jpeg|gif|ico|svg|webp|woff2?|ttf|otf)$ {
expires 7d;
add_header Cache-Control "public";
access_log off;
}
# === SEO files (1 hour) ===
location ~* ^/(?:robots\.txt|sitemap\.xml|manifest\.json)$ {
expires 1h;
add_header Cache-Control "public";
access_log off;
}
# === SPA fallback serve index.html for any unmatched route ===
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# === Health endpoint ===
location = /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# === Block dot-files (except .well-known) ===
location ~ /\.(?!well-known).* {
deny all;
access_log off;
log_not_found off;
}
}

247
index.html Normal file
View File

@ -0,0 +1,247 @@
<!doctype html>
<html lang="en" prefix="og: https://ogp.me/ns#">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!-- Primary Meta Tags -->
<title>TRON Parity Ladder — Price Parity & Market Cap Flip Ratio Dashboard</title>
<meta name="title" content="TRON Parity Ladder — Price Parity & Market Cap Flip Ratio Dashboard" />
<meta name="description" content="Track how many X until TRON ecosystem tokens (TRX, JST, SUN, BTT, WIN, APENFT) reach price parity with USD, EUR, GBP, KWD, and 27 global currencies. Live forex parity ladder, market-cap flip targets, sortable comparison matrices. USD = 1 base anchor." />
<meta name="keywords" content="TRON, TRX, price parity, market cap flip, crypto dashboard, TRON ecosystem, JST, SUN, BTT, WINkLink, APENFT, USDD, forex parity, crypto ratios, flip ratio, parity ladder, cryptocurrency, DeFi, blockchain, exchange rates, USD parity, token comparison, market cap ranking, crypto tools, TRON DeFi, price targets, KWD, BHD, GBP, CHF, EUR, SGD, CAD, AUD, NZD, JPY, KRW, IDR, INR, THB, TRY, MXN, ZAR, BRL, AED, CNY, HKD, NOK, SEK, DKK, Kuwaiti Dinar, forex ladder, currency strength" />
<meta name="author" content="TRON Parity Ladder" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta name="googlebot" content="index, follow" />
<meta name="theme-color" content="#FF060A" />
<meta name="color-scheme" content="dark" />
<meta name="application-name" content="TRON Parity Ladder" />
<meta name="apple-mobile-web-app-title" content="TRON Parity" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<!-- Canonical URL -->
<link rel="canonical" href="https://tron-parity-ladder.com/" />
<!-- Favicon & Manifest -->
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
<link rel="manifest" href="/manifest.json" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://tron-parity-ladder.com/" />
<meta property="og:title" content="TRON Parity Ladder — Price Parity & Market Cap Flip Ratios" />
<meta property="og:description" content="Calculate how many X until TRX, JST, SUN, BTT, WIN, and APENFT reach $1, €1, or flip each other by market cap. Live forex ratios, sortable ladder, comparison matrix. The TRON ecosystem research framework." />
<meta property="og:image" content="https://tron-parity-ladder.com/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="TRON Parity Ladder Dashboard — Price Parity & Market Cap Flip Ratios" />
<meta property="og:site_name" content="TRON Parity Ladder" />
<meta property="og:locale" content="en_US" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://tron-parity-ladder.com/" />
<meta name="twitter:title" content="TRON Parity Ladder — Price Parity & Market Cap Flip Ratios" />
<meta name="twitter:description" content="How many X until TRX hits $1? Track TRON ecosystem price parity goals, forex ratios, and market-cap flip targets. Live data, sortable ladders, interactive matrix." />
<meta name="twitter:image" content="https://tron-parity-ladder.com/og-image.jpg" />
<meta name="twitter:image:alt" content="TRON Parity Ladder Dashboard" />
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "TRON Parity Ladder",
"alternateName": "TRON Flip Ratio Framework",
"url": "https://tron-parity-ladder.com/",
"description": "Track how many X until TRON ecosystem tokens reach price parity with USD and other fiat currencies, or flip each other by market cap. Live forex ratios and sortable comparison tools.",
"applicationCategory": "FinanceApplication",
"operatingSystem": "Web",
"browserRequirements": "Requires JavaScript",
"softwareVersion": "1.0",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Organization",
"name": "TRON Parity Ladder"
},
"screenshot": "https://tron-parity-ladder.com/og-image.jpg",
"featureList": [
"Price parity ratio calculator",
"Market-cap flip targets",
"Live forex parity ladder",
"Token comparison matrix",
"TRON ecosystem tracker",
"Real-time data refresh"
],
"keywords": "TRON, TRX, price parity, market cap, flip ratio, crypto dashboard, forex, DeFi"
}
</script>
<!-- FAQ Structured Data for rich snippets -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "How many X does TRX need to reach $1?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The TRON Parity Ladder calculates the live X multiplier by dividing the target price ($1.00) by TRX's current USD price. This ratio updates in real-time as TRX price changes."
}
},
{
"@type": "Question",
"name": "What is price parity vs market-cap flip?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Price parity measures how many X until 1 token equals a target price (e.g. TRX to $1). Market-cap flip measures how many X until one project's total valuation surpasses another (e.g. JST flipping SUN by market cap). These are fundamentally different metrics."
}
},
{
"@type": "Question",
"name": "Which TRON tokens are tracked?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The dashboard tracks TRX, JST (JUST), SUN (SUN.io), BTT (BitTorrent), WIN (WINkLink), NFT (APENFT), and USDD. Stablecoins like USDD are excluded from market-cap flip calculations."
}
},
{
"@type": "Question",
"name": "What forex currencies are included?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The forex parity ladder tracks 11 currencies: USD (base anchor), EUR, GBP, AUD, NZD, CAD, CHF, JPY, CNY, SGD, and HKD. All are ranked by strength against USD = 1."
}
},
{
"@type": "Question",
"name": "How is the parity X ratio calculated?",
"acceptedAnswer": {
"@type": "Answer",
"text": "For price parity: X = target price in USD ÷ token price in USD. For market-cap flips: X = target market cap ÷ base market cap. All values are normalized to USD first."
}
}
]
}
</script>
<!-- FinancialProduct Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Dataset",
"name": "TRON Ecosystem Parity Ratios",
"description": "Live price parity ratios and market-cap flip multipliers for TRON ecosystem tokens including TRX, JST, SUN, BTT, WIN, and APENFT against USD, EUR, GBP, and other major currencies.",
"url": "https://tron-parity-ladder.com/",
"keywords": ["TRON", "TRX", "cryptocurrency", "price parity", "market cap", "forex", "exchange rates"],
"license": "https://creativecommons.org/licenses/by/4.0/",
"creator": {
"@type": "Organization",
"name": "TRON Parity Ladder"
},
"temporalCoverage": "2026/..",
"variableMeasured": [
{
"@type": "PropertyValue",
"name": "Price Parity Ratio",
"description": "X multiplier to reach target price"
},
{
"@type": "PropertyValue",
"name": "Market Cap Flip Ratio",
"description": "X multiplier to surpass target market capitalization"
}
]
}
</script>
<!-- Breadcrumb Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "TRON Parity Ladder",
"item": "https://tron-parity-ladder.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Price Parity",
"item": "https://tron-parity-ladder.com/#price-parity"
},
{
"@type": "ListItem",
"position": 3,
"name": "Market-Cap Flips",
"item": "https://tron-parity-ladder.com/#flips"
}
]
}
</script>
<!-- Preconnect & DNS Prefetch for performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://api.coingecko.com" />
<link rel="dns-prefetch" href="https://api.frankfurter.dev" />
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Fragment+Mono&display=swap" rel="stylesheet" />
<!-- Umami analytics -->
<script
defer
src="https://analytics.degelas.be/script.js"
data-website-id="4e6a3230-e474-45cc-a07f-2e890056bfb4"
data-domains="tron.degelas.be"
></script>
</head>
<body>
<!-- Noscript fallback for SEO crawlers -->
<noscript>
<div style="padding:40px;max-width:800px;margin:0 auto;font-family:system-ui,sans-serif;color:#ededed;background:#0a0a0a;min-height:100vh">
<h1>TRON Parity Ladder — Price Parity & Market Cap Flip Ratio Dashboard</h1>
<p>Track how many X until TRON ecosystem tokens reach price parity with USD, EUR, GBP, and other global currencies. Compare market-cap flip targets across TRX, JST, SUN, BTT, WIN, and APENFT.</p>
<h2>What is Price Parity?</h2>
<p>Price parity calculates how many X (multiplier) a token needs to reach a target price. Formula: X = target price ÷ current token price. For example, if TRX is $0.374, it needs 2.67× to reach $1.00.</p>
<h2>What is a Market-Cap Flip?</h2>
<p>A market-cap flip occurs when one project's total valuation surpasses another. Formula: X = target market cap ÷ base market cap. This is different from price parity because it accounts for circulating supply.</p>
<h2>Tracked TRON Ecosystem Tokens</h2>
<ul>
<li><strong>TRX</strong> — TRON native token</li>
<li><strong>JST</strong> — JUST DeFi ecosystem</li>
<li><strong>SUN</strong> — SUN.io DEX & DeFi</li>
<li><strong>BTT</strong> — BitTorrent ecosystem</li>
<li><strong>WIN</strong> — WINkLink oracle infrastructure</li>
<li><strong>NFT</strong> — APENFT NFT ecosystem</li>
<li><strong>USDD</strong> — TRON-native stablecoin</li>
</ul>
<h2>Forex Parity Ladder</h2>
<p>Compare 27 global currencies ranked by strength against USD = 1. Strongest: KWD (Kuwaiti Dinar $3.26), BHD (Bahraini Dinar $2.65), OMR (Omani Rial $2.60), GBP, CHF, EUR. Weakest: KRW, IDR, JPY.</p>
<h2>Key Formulas</h2>
<ul>
<li>Price Parity: X = Price(target in USD) ÷ Price(base in USD)</li>
<li>Market-Cap Flip: X = MarketCap(target) ÷ MarketCap(base)</li>
<li>Progress to Target: 1/X × 100%</li>
</ul>
<p>This dashboard uses USD as the base reference unit. All fiat and crypto assets are normalized into USD. Prices and market caps are live estimates from third-party data providers.</p>
<p>Please enable JavaScript to use the interactive dashboard.</p>
</div>
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2579
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "react-vite-tailwind",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "2.1.1",
"lightweight-charts": "^5.2.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.17",
"@types/node": "22.19.17",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"tailwindcss": "4.1.17",
"typescript": "5.9.3",
"vite": "7.3.2",
"vite-plugin-singlefile": "2.3.0"
}
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

21
public/manifest.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "TRON Parity Ladder — Price Parity & Market Cap Flip Dashboard",
"short_name": "TRON Parity",
"description": "Track how many X until TRON tokens reach price parity or flip each other by market cap. Live forex ratios, sortable ladders, and interactive comparison tools.",
"start_url": "/",
"display": "standalone",
"background_color": "#0A0A0A",
"theme_color": "#FF060A",
"orientation": "any",
"categories": ["finance", "business", "utilities"],
"lang": "en",
"dir": "ltr",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

31
public/robots.txt Normal file
View File

@ -0,0 +1,31 @@
# TRON Parity Ladder - Robots.txt
User-agent: *
Allow: /
# Sitemap location
Sitemap: https://tron-parity-ladder.com/sitemap.xml
# Crawl-delay (be respectful)
Crawl-delay: 1
# Allow all search engines
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Slurp
Allow: /
User-agent: DuckDuckBot
Allow: /
User-agent: Baiduspider
Allow: /
User-agent: YandexBot
Allow: /
# Block unnecessary paths
Disallow: /assets/

39
public/sitemap.xml Normal file
View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- Homepage / Overview -->
<url>
<loc>https://tron-parity-ladder.com/</loc>
<lastmod>2026-05-27</lastmod>
<changefreq>hourly</changefreq>
<priority>1.0</priority>
</url>
<!-- Price Parity View -->
<url>
<loc>https://tron-parity-ladder.com/#price-parity</loc>
<lastmod>2026-05-27</lastmod>
<changefreq>hourly</changefreq>
<priority>0.9</priority>
</url>
<!-- Comparison Matrix -->
<url>
<loc>https://tron-parity-ladder.com/#matrix</loc>
<lastmod>2026-05-27</lastmod>
<changefreq>hourly</changefreq>
<priority>0.8</priority>
</url>
<!-- Market-Cap Flips -->
<url>
<loc>https://tron-parity-ladder.com/#flips</loc>
<lastmod>2026-05-27</lastmod>
<changefreq>hourly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

1920
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,242 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { createChart, IChartApi, ISeriesApi, LineStyle, AreaSeries } from "lightweight-charts";
import {
fetchBinanceKlines,
buildSyntheticPair,
buildUsdSeries,
BINANCE_SUPPORTED,
type OHLCV,
} from "../services/binanceService";
import { storeKlines, getKlines, hasRecentKlines } from "../services/ohlcvStore";
interface Props {
base: string;
quote: string;
mode: "synthetic" | "usd";
height?: number;
}
export default function SyntheticChart({ base, quote, mode, height = 420 }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<"Area"> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dataInfo, setDataInfo] = useState<{ points: number; from: string; to: string } | null>(null);
const loadAndRender = useCallback(async () => {
setLoading(true);
setError(null);
try {
// 1) Ensure we have klines for both symbols
const symbols = mode === "synthetic" ? [base, quote] : [base];
const ohlcvMap: Record<string, OHLCV[]> = {};
for (const sym of symbols) {
const lower = sym.toLowerCase();
let data: OHLCV[] = [];
// Always try to fetch fresh data first for Binance-supported tokens
if (BINANCE_SUPPORTED.includes(lower)) {
try {
// Check if we have recent cached data
const cached = await getKlines(lower);
const hasRecent = cached.length > 0 && await hasRecentKlines(lower, 6);
if (!hasRecent) {
// Fetch fresh from Binance
const fresh = await fetchBinanceKlines(lower, 365);
if (fresh.length > 0) {
await storeKlines(lower, fresh);
data = fresh;
} else {
data = cached;
}
} else {
data = cached;
}
} catch (err) {
// Fetch failed, use cached if available
data = await getKlines(lower);
}
} else {
// Not supported by Binance, use cached only
data = await getKlines(lower);
}
ohlcvMap[lower] = data;
}
// 2) Build series
let seriesData: { time: number; value: number }[] = [];
if (mode === "synthetic") {
const baseData = ohlcvMap[base.toLowerCase()] ?? [];
const quoteData = ohlcvMap[quote.toLowerCase()] ?? [];
if (baseData.length === 0 || quoteData.length === 0) {
setError(`Insufficient data for ${base}/${quote}. Binance may not support one of these tokens.`);
setLoading(false);
return;
}
const synth = buildSyntheticPair(baseData, quoteData, base, quote);
seriesData = synth.data;
} else {
const usdData = ohlcvMap[base.toLowerCase()] ?? [];
if (usdData.length === 0) {
setError(`No USD price data for ${base.toUpperCase()}. Binance may not support this token.`);
setLoading(false);
return;
}
const usd = buildUsdSeries(usdData, base);
seriesData = usd.data;
}
if (seriesData.length === 0) {
setError("No data points to display. Please try another token pair.");
setLoading(false);
return;
}
// 3) Render chart
if (!containerRef.current) return;
// Destroy previous chart
if (chartRef.current) {
chartRef.current.remove();
chartRef.current = null;
seriesRef.current = null;
}
const chart = createChart(containerRef.current, {
layout: {
background: { color: "#0A0A0A" },
textColor: "#888",
fontFamily: "'Fragment Mono', monospace",
},
grid: {
vertLines: { color: "#141414", style: LineStyle.Solid },
horzLines: { color: "#141414", style: LineStyle.Solid },
},
crosshair: {
mode: 1,
vertLine: { color: "#FF060A", width: 1, style: LineStyle.Dashed, labelBackgroundColor: "#FF060A" },
horzLine: { color: "#FF060A", width: 1, style: LineStyle.Dashed, labelBackgroundColor: "#FF060A" },
},
rightPriceScale: {
borderColor: "#1A1A1A",
scaleMargins: { top: 0.1, bottom: 0.1 },
},
timeScale: {
borderColor: "#1A1A1A",
timeVisible: false,
secondsVisible: false,
},
handleScroll: { vertTouchDrag: false },
handleScale: { axisPressedMouseMove: true },
autoSize: true,
});
chartRef.current = chart;
const series = chart.addSeries(AreaSeries, {
topColor: "rgba(255, 6, 10, 0.3)",
bottomColor: "rgba(255, 6, 10, 0.01)",
lineColor: "#FF060A",
lineWidth: 2,
lastValueVisible: true,
priceLineVisible: true,
priceLineColor: "#FF060A",
priceLineWidth: 1,
priceLineStyle: LineStyle.Dashed,
});
seriesRef.current = series;
// Convert timestamps to lightweight-charts format (seconds)
const chartData = seriesData.map((d) => ({
time: d.time as any,
value: d.value,
}));
series.setData(chartData);
chart.timeScale().fitContent();
// Tooltip
chart.subscribeCrosshairMove((param) => {
if (!param.time || !param.point) return;
const data = param.seriesData.get(series) as any;
if (data) {
// Tooltip is handled natively by lightweight-charts
}
});
setDataInfo({
points: seriesData.length,
from: new Date(seriesData[0].time * 1000).toLocaleDateString(),
to: new Date(seriesData[seriesData.length - 1].time * 1000).toLocaleDateString(),
});
} catch (err) {
setError(err instanceof Error ? err.message : "Chart load failed");
} finally {
setLoading(false);
}
}, [base, quote, mode]);
useEffect(() => {
loadAndRender();
return () => {
if (chartRef.current) {
chartRef.current.remove();
chartRef.current = null;
}
};
}, [loadAndRender]);
return (
<div className="rounded-[20px] border border-[#1A1A1A] bg-[#0F0F0F] overflow-hidden">
{/* Header */}
<div className="px-[20px] h-[52px] flex items-center justify-between border-b border-[#1A1A1A]">
<div className="flex items-center gap-[10px]">
<div className="h-[8px] w-[8px] rounded-full bg-[#FF060A] animate-pulse" />
<span className="mono text-[13px] font-[550] tracking-[-0.01em] text-white">
{mode === "synthetic" ? `${base.toUpperCase()}/${quote.toUpperCase()}` : `${base.toUpperCase()}/USD`}
</span>
<span className="mono text-[10px] px-[7px] h-[18px] rounded-[6px] bg-[#1A1A1A] text-[#888] uppercase tracking-wide flex items-center">
1D
</span>
</div>
{dataInfo && (
<span className="mono text-[10px] tracking-wide text-[#666]">
{dataInfo.points} pts {dataInfo.from} {dataInfo.to}
</span>
)}
</div>
{/* Chart area */}
<div className="relative" style={{ height }}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#0A0A0A]/80 z-10">
<div className="flex items-center gap-[10px]">
<div className="h-[16px] w-[16px] rounded-full border-2 border-[#FF060A] border-t-transparent animate-spin" />
<span className="mono text-[12px] tracking-wide text-[#999]">Loading Binance 1D</span>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-[#0A0A0A]/80 z-10">
<div className="text-center px-[20px]">
<div className="text-[13px] text-[#FF6B70] mb-[6px]">{error}</div>
<button
onClick={loadAndRender}
className="px-[12px] h-[28px] rounded-[8px] bg-[#1A1A1A] hover:bg-[#222] border border-[#222] text-[#AAA] hover:text-white text-[11px] font-[500] transition-all"
>
Retry
</button>
</div>
</div>
)}
<div ref={containerRef} className="w-full h-full" />
</div>
</div>
);
}

1
src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@ -0,0 +1,147 @@
// ============================================================
// Binance OHLCV Data Service
// ============================================================
// Fetches daily (1d) candlestick data from Binance public API.
// No API key required. Data is stored in IndexedDB for
// synthetic-pair chart rendering (e.g. BTT/JST, APENFT/WIN).
//
// Endpoint: https://api.binance.com/api/v3/klines
// Format: [timestamp, open, high, low, close, volume, ...]
//
// For tokens not on Binance, falls back to stored snapshots
// from the main dataService history pipeline.
// ============================================================
export interface OHLCV {
time: number; // unix timestamp (seconds)
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface SyntheticSeries {
base: string;
quote: string;
data: { time: number; value: number }[];
}
// Binance symbol mapping for TRON ecosystem tokens
const BINANCE_SYMBOLS: Record<string, string> = {
trx: "TRXUSDT",
jst: "JSTUSDT",
sun: "SUNUSDT",
btt: "BTTUSDT",
win: "WINUSDT",
nft: "NFTUSDT", // may not exist — handled gracefully
usdd: "USDDUSDT",
};
// Tokens confirmed on Binance (subset that actually trades)
export const BINANCE_SUPPORTED = ["trx", "jst", "sun", "btt", "win", "usdd"];
// ----------------------------------------------------------------
// Fetch 1D klines from Binance
// ----------------------------------------------------------------
export async function fetchBinanceKlines(
symbol: string,
limit = 365
): Promise<OHLCV[]> {
const binanceSymbol = BINANCE_SYMBOLS[symbol.toLowerCase()];
if (!binanceSymbol) {
throw new Error(`No Binance symbol mapping for ${symbol}`);
}
const url = `https://api.binance.com/api/v3/klines?symbol=${binanceSymbol}&interval=1d&limit=${limit}`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`Binance HTTP ${res.status}`);
}
const raw = await res.json();
if (!Array.isArray(raw)) {
throw new Error("Invalid Binance response");
}
return raw.map((c: any[]) => ({
time: Math.floor(c[0] / 1000), // ms → s
open: parseFloat(c[1]),
high: parseFloat(c[2]),
low: parseFloat(c[3]),
close: parseFloat(c[4]),
volume: parseFloat(c[5]),
}));
}
// ----------------------------------------------------------------
// Fetch multiple tokens in parallel
// ----------------------------------------------------------------
export async function fetchMultipleKlines(
symbols: string[],
limit = 365
): Promise<Record<string, OHLCV[]>> {
const results: Record<string, OHLCV[]> = {};
await Promise.all(
symbols.map(async (sym) => {
try {
const data = await fetchBinanceKlines(sym, limit);
results[sym.toLowerCase()] = data;
} catch (err) {
console.warn(`Binance klines failed for ${sym}:`, err);
results[sym.toLowerCase()] = [];
}
})
);
return results;
}
// ----------------------------------------------------------------
// Build synthetic pair series from two OHLCV sets
// Example: base="btt", quote="jst" → BTT/JST ratio over time
// ----------------------------------------------------------------
export function buildSyntheticPair(
baseData: OHLCV[],
quoteData: OHLCV[],
baseSymbol: string,
quoteSymbol: string
): SyntheticSeries {
// Index quote data by timestamp for fast lookup
const quoteMap = new Map<number, OHLCV>();
for (const q of quoteData) {
quoteMap.set(q.time, q);
}
const synthetic: { time: number; value: number }[] = [];
for (const b of baseData) {
const q = quoteMap.get(b.time);
if (!q || q.close === 0) continue;
synthetic.push({
time: b.time,
value: b.close / q.close,
});
}
return {
base: baseSymbol.toUpperCase(),
quote: quoteSymbol.toUpperCase(),
data: synthetic,
};
}
// ----------------------------------------------------------------
// Build USD-denominated price series (just the close prices)
// ----------------------------------------------------------------
export function buildUsdSeries(data: OHLCV[], symbol: string) {
return {
symbol: symbol.toUpperCase(),
data: data.map((d) => ({ time: d.time, value: d.close })),
};
}

315
src/services/dataService.ts Normal file
View File

@ -0,0 +1,315 @@
// ============================================================
// TRON Parity Ladder — Central Data Service
// ============================================================
// Abstraction layer for fetching and caching TRON market data.
// Designed to swap between multiple data sources (TronScan,
// CoinGecko, Frankfurter) and persist snapshots to IndexedDB
// for future time-series / synthetic chart features.
//
// Architecture path:
// Phase 1 (now) → Browser fetch + IndexedDB history
// Phase 2 (next) → Node worker + Postgres + REST API
// ============================================================
import { recordPriceSnapshot } from "./historyStore";
export interface RawTokenData {
id: string;
symbol: string;
name: string;
price_usd: number;
market_cap_usd: number;
circulating_supply: number;
change_24h: number;
source: "tronscan" | "coingecko" | "fallback";
last_updated: string; // ISO timestamp
}
export interface RawForexData {
base: string;
date: string;
rates: Record<string, number>;
source: "frankfurter" | "fallback";
}
// ----------------------------------------------------------------
// TRONSCAN ENDPOINTS (Primary — on-chain TRON data)
// ----------------------------------------------------------------
// Docs: https://docs.tronscan.org/api-endpoints/tokens
// Base: https://apilist.tronscan.org
//
// Endpoints we use:
// /api/token/trc20?contract=<addr> → single TRC20 token
// /api/token_price?token=trx → TRX price ticker
// /api/statistic/latest → ecosystem-wide stats
// ----------------------------------------------------------------
const TRONSCAN_BASE = "https://apilist.tronscan.org";
// Canonical TRC20 contract addresses on TRON mainnet
const TRC20_CONTRACTS: Record<string, string> = {
jst: "TCFLL5dx5ZJd5WAwY7DCxAVHbQXqR5YZv1",
sun: "TSSMHYeV2uE9qYH95DqyoCuEz8E23GfJwP",
btt: "TAFjULxiVgT4qWk6UZwjqwZXTSaGaqnVp4",
win: "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
nft: "TFczxzPhnThNSqr5by8tvxsdCFRRz6cPNq",
usdd: "TPYmHEhy5n8TCEfYGqW2rRcMJmScJXNdSK",
};
// ----------------------------------------------------------------
// Helper: fetch with timeout & retry
// ----------------------------------------------------------------
async function fetchWithTimeout(
url: string,
timeoutMs = 8000,
retries = 2
): Promise<Response> {
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
clearTimeout(timeoutId);
if (attempt === retries) throw err;
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
throw new Error("unreachable");
}
// ----------------------------------------------------------------
// TronScan: Fetch TRX native price
// ----------------------------------------------------------------
async function fetchTrxPriceTronScan(): Promise<{ price: number; change: number } | null> {
try {
const res = await fetchWithTimeout(
`${TRONSCAN_BASE}/api/token_price?token=trx`,
6000,
1
);
const data = await res.json();
if (data?.price) {
return {
price: parseFloat(data.price),
change: parseFloat(data.change ?? "0"),
};
}
return null;
} catch {
return null;
}
}
// ----------------------------------------------------------------
// TronScan: Fetch TRC20 token info (price, supply, market cap)
// ----------------------------------------------------------------
async function fetchTrc20FromTronScan(
symbol: string,
contract: string
): Promise<RawTokenData | null> {
try {
const res = await fetchWithTimeout(
`${TRONSCAN_BASE}/api/token_trc20?start=0&limit=1&contract=${contract}`,
6000,
1
);
const data = await res.json();
const token = data?.data?.[0];
if (!token) return null;
const price = parseFloat(token.priceInUsd ?? "0");
const supply = parseFloat(token.totalSupply ?? token.holderCount ?? "0");
// TronScan totalSupply is in raw units; divide by 10^decimals
const decimals = parseInt(token.decimals ?? "6", 10);
const normalizedSupply = supply / Math.pow(10, decimals);
const marketCap = price * normalizedSupply;
return {
id: symbol.toLowerCase(),
symbol: symbol.toUpperCase(),
name: token.name || symbol.toUpperCase(),
price_usd: price,
market_cap_usd: marketCap,
circulating_supply: normalizedSupply,
change_24h: parseFloat(token.gain ?? "0"),
source: "tronscan",
last_updated: new Date().toISOString(),
};
} catch {
return null;
}
}
// ----------------------------------------------------------------
// CoinGecko: Fallback for TRON ecosystem tokens
// ----------------------------------------------------------------
const COINGECKO_IDS: Record<string, string> = {
trx: "tron",
jst: "just",
sun: "sun-token",
btt: "bittorrent-new",
win: "wink",
nft: "apenft",
usdd: "usdd",
};
async function fetchCoinGeckoBatch(symbols: string[]): Promise<RawTokenData[]> {
try {
const ids = symbols
.map((s) => COINGECKO_IDS[s.toLowerCase()])
.filter(Boolean)
.join(",");
if (!ids) return [];
const res = await fetchWithTimeout(
`https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${ids}&order=market_cap_desc&per_page=20&page=1&sparkline=false&price_change_percentage=24h`,
8000,
1
);
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.map((t: any) => ({
id: Object.keys(COINGECKO_IDS).find((k) => COINGECKO_IDS[k] === t.id) ?? t.id,
symbol: (t.symbol ?? "").toUpperCase(),
name: t.name,
price_usd: t.current_price ?? 0,
market_cap_usd: t.market_cap ?? 0,
circulating_supply: t.circulating_supply ?? 0,
change_24h: t.price_change_percentage_24h ?? 0,
source: "coingecko",
last_updated: t.last_updated ?? new Date().toISOString(),
}));
} catch {
return [];
}
}
// ----------------------------------------------------------------
// Forex: Frankfurter (ECB rates, free, CORS-enabled)
// ----------------------------------------------------------------
const FIAT_SYMBOLS = [
"EUR", "GBP", "CHF", "AUD", "NZD", "CAD", "SGD",
"JPY", "CNY", "HKD", "SEK", "NOK", "DKK", "BRL",
"ZAR", "MXN", "THB", "TRY", "INR", "PHP", "KRW", "IDR",
];
async function fetchForexRates(): Promise<RawForexData | null> {
try {
const res = await fetchWithTimeout(
`https://api.frankfurter.dev/v1/latest?base=USD&symbols=${FIAT_SYMBOLS.join(",")}`,
6000,
1
);
const data = await res.json();
return {
base: "USD",
date: data.date ?? new Date().toISOString().slice(0, 10),
rates: data.rates ?? {},
source: "frankfurter",
};
} catch {
return null;
}
}
// ----------------------------------------------------------------
// PUBLIC: Fetch all market data (TronScan primary, CoinGecko fallback)
// ----------------------------------------------------------------
export interface MarketSnapshot {
tokens: RawTokenData[];
forex: RawForexData;
fetched_at: string;
}
export async function fetchMarketData(): Promise<MarketSnapshot> {
const fetched_at = new Date().toISOString();
const targetSymbols = ["trx", "jst", "sun", "btt", "win", "nft", "usdd"];
// 1) Attempt TronScan for TRX
const trxFromTronScan = await fetchTrxPriceTronScan();
// 2) Attempt TronScan for each TRC20
const trc20Tasks = Object.entries(TRC20_CONTRACTS).map(([sym, contract]) =>
fetchTrc20FromTronScan(sym, contract)
);
const trc20Results = await Promise.all(trc20Tasks);
// 3) Fill gaps with CoinGecko batch
const tronscanHits = new Map<string, RawTokenData>();
if (trxFromTronScan) {
tronscanHits.set("trx", {
id: "trx",
symbol: "TRX",
name: "TRON",
price_usd: trxFromTronScan.price,
market_cap_usd: 0, // filled by CoinGecko if needed
circulating_supply: 0,
change_24h: trxFromTronScan.change,
source: "tronscan",
last_updated: fetched_at,
});
}
trc20Results.forEach((r) => {
if (r) tronscanHits.set(r.id, r);
});
const missingSymbols = targetSymbols.filter((s) => !tronscanHits.has(s));
const coingeckoFill = missingSymbols.length > 0
? await fetchCoinGeckoBatch(missingSymbols)
: [];
// 4) Merge: TronScan wins, CoinGecko fills, fallback constant otherwise
const tokens: RawTokenData[] = targetSymbols.map((sym) => {
const fromTronScan = tronscanHits.get(sym);
if (fromTronScan && fromTronScan.price_usd > 0) return fromTronScan;
const fromCoinGecko = coingeckoFill.find(
(t) => t.id.toLowerCase() === sym.toLowerCase()
);
if (fromCoinGecko && fromCoinGecko.price_usd > 0) return fromCoinGecko;
// Last-resort fallback with last-known values
return {
id: sym,
symbol: sym.toUpperCase(),
name: sym.toUpperCase(),
price_usd: 0,
market_cap_usd: 0,
circulating_supply: 0,
change_24h: 0,
source: "fallback",
last_updated: fetched_at,
};
});
// 5) Forex
const forex = (await fetchForexRates()) ?? {
base: "USD",
date: new Date().toISOString().slice(0, 10),
rates: {},
source: "fallback" as const,
};
const snapshot: MarketSnapshot = { tokens, forex, fetched_at };
// 6) Persist snapshot to IndexedDB for time-series / future charts
try {
await recordPriceSnapshot(snapshot);
} catch (err) {
console.warn("History record failed (non-fatal):", err);
}
return snapshot;
}
// ----------------------------------------------------------------
// PUBLIC: Read historical snapshots from IndexedDB
// ----------------------------------------------------------------
export { getSnapshots, clearHistory, getSyntheticPairHistory } from "./historyStore";

View File

@ -0,0 +1,220 @@
// ============================================================
// TRON Parity Ladder — History Store (IndexedDB)
// ============================================================
// Persists every price snapshot fetched by the data service into
// the browser's IndexedDB. This enables:
//
// • Offline resilience (data survives page reload)
// • Local time-series for charts (per-user device)
// • Synthetic pair history (e.g. BTT/JST, APENFT/WIN)
//
// Storage layout (IDB schema v1):
// Database: "tron-parity-history"
// Object store: "snapshots" (keyPath: "timestamp")
// → each record: { timestamp, tokens: { [id]: price, mc, supply, change, source }, forex }
//
// Retention: default 30 days (configurable). Auto-pruned on write.
// ============================================================
import type { MarketSnapshot } from "./dataService";
const DB_NAME = "tron-parity-history";
const DB_VERSION = 1;
const STORE_NAME = "snapshots";
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// ----------------------------------------------------------------
// Open / create the IndexedDB connection
// ----------------------------------------------------------------
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "timestamp" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------
// Shape the snapshot into a compact record
// ----------------------------------------------------------------
interface HistoryRecord {
timestamp: number; // unix ms
tokens: Record<string, {
price: number;
mc: number;
supply: number;
change: number;
src: string;
}>;
forex: Record<string, number>;
}
function toRecord(snap: MarketSnapshot): HistoryRecord {
const tokens: HistoryRecord["tokens"] = {};
for (const t of snap.tokens) {
tokens[t.id.toLowerCase()] = {
price: t.price_usd,
mc: t.market_cap_usd,
supply: t.circulating_supply,
change: t.change_24h,
src: t.source,
};
}
return {
timestamp: Date.now(),
tokens,
forex: snap.forex.rates,
};
}
// ----------------------------------------------------------------
// Write a snapshot (and prune old records)
// ----------------------------------------------------------------
export async function recordPriceSnapshot(snap: MarketSnapshot): Promise<void> {
const db = await openDB();
const record = toRecord(snap);
const cutoff = Date.now() - RETENTION_MS;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
// Write new record
store.put(record);
// Prune records older than retention window
const range = IDBKeyRange.upperBound(cutoff);
const cursorReq = store.openCursor(range);
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (cursor) {
store.delete(cursor.primaryKey);
cursor.continue();
}
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ----------------------------------------------------------------
// Read all snapshots in a time range (default: all within retention)
// ----------------------------------------------------------------
export interface StoredSnapshot {
timestamp: number;
tokens: Record<string, {
price: number;
mc: number;
supply: number;
change: number;
src: string;
}>;
forex: Record<string, number>;
}
export async function getSnapshots(opts?: {
since?: number;
until?: number;
limit?: number;
}): Promise<StoredSnapshot[]> {
const db = await openDB();
const since = opts?.since ?? Date.now() - RETENTION_MS;
const until = opts?.until ?? Date.now();
const limit = opts?.limit ?? Infinity;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const results: StoredSnapshot[] = [];
const range = IDBKeyRange.bound(since, until);
const req = store.openCursor(range);
req.onsuccess = () => {
const cursor = req.result;
if (cursor && results.length < limit) {
results.push(cursor.value as StoredSnapshot);
cursor.continue();
} else {
resolve(results);
}
};
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------
// Synthetic pair history: e.g. BTT/JST, APENFT/WIN
//
// Computes price(base) / price(quote) for each timestamp.
// Example: syntheticPairHistory("btt", "jst") returns a series
// where each point = "how many BTT equal 1 JST" at that time.
// ----------------------------------------------------------------
export interface SyntheticPoint {
timestamp: number;
ratio: number; // price(base) / price(quote)
basePrice: number;
quotePrice: number;
}
export async function getSyntheticPairHistory(
base: string,
quote: string,
opts?: { since?: number; until?: number }
): Promise<SyntheticPoint[]> {
const snaps = await getSnapshots(opts);
const baseKey = base.toLowerCase();
const quoteKey = quote.toLowerCase();
const points: SyntheticPoint[] = [];
for (const s of snaps) {
const b = s.tokens[baseKey];
const q = s.tokens[quoteKey];
if (!b || !q || !q.price || !b.price) continue;
points.push({
timestamp: s.timestamp,
ratio: b.price / q.price,
basePrice: b.price,
quotePrice: q.price,
});
}
return points;
}
// ----------------------------------------------------------------
// Clear all stored snapshots (for debugging / privacy)
// ----------------------------------------------------------------
export async function clearHistory(): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ----------------------------------------------------------------
// Diagnostic: storage stats (used by the dev footer)
// ----------------------------------------------------------------
export async function getStorageStats(): Promise<{
recordCount: number;
oldestTimestamp: number | null;
newestTimestamp: number | null;
}> {
const snaps = await getSnapshots();
if (snaps.length === 0) {
return { recordCount: 0, oldestTimestamp: null, newestTimestamp: null };
}
return {
recordCount: snaps.length,
oldestTimestamp: snaps[0].timestamp,
newestTimestamp: snaps[snaps.length - 1].timestamp,
};
}

176
src/services/ohlcvStore.ts Normal file
View File

@ -0,0 +1,176 @@
// ============================================================
// OHLCV IndexedDB Store
// ============================================================
// Dedicated storage for daily candlestick data fetched from
// Binance. Separate from the main snapshot store so we can
// keep years of 1D data without bloating the live-snapshot DB.
//
// Database: "tron-parity-ohlcv"
// Object store: "klines" (keyPath: compound [symbol+time])
//
// Synthetic pair charts read from here, compute ratios on-the-fly.
// ============================================================
import type { OHLCV } from "./binanceService";
const DB_NAME = "tron-parity-ohlcv";
const DB_VERSION = 1;
const STORE_NAME = "klines";
interface KlineRecord {
id: string; // "trx_1698796800"
symbol: string; // "trx"
time: number; // unix seconds
open: number;
high: number;
low: number;
close: number;
volume: number;
fetchedAt: string; // ISO
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
store.createIndex("symbol_time", ["symbol", "time"], { unique: true });
store.createIndex("symbol", "symbol", { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------
// Write klines for a single symbol (batch insert with transaction)
// ----------------------------------------------------------------
export async function storeKlines(symbol: string, data: OHLCV[]): Promise<void> {
if (data.length === 0) return;
const db = await openDB();
const lower = symbol.toLowerCase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const fetchedAt = new Date().toISOString();
for (const d of data) {
const record: KlineRecord = {
id: `${lower}_${d.time}`,
symbol: lower,
time: d.time,
open: d.open,
high: d.high,
low: d.low,
close: d.close,
volume: d.volume,
fetchedAt,
};
store.put(record);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ----------------------------------------------------------------
// Read klines for a symbol, optionally filtered by time range
// ----------------------------------------------------------------
export async function getKlines(
symbol: string,
opts?: { since?: number; until?: number }
): Promise<OHLCV[]> {
const db = await openDB();
const lower = symbol.toLowerCase();
const since = opts?.since ?? 0;
const until = opts?.until ?? Infinity;
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const idx = store.index("symbol");
const results: OHLCV[] = [];
const req = idx.openCursor(IDBKeyRange.only(lower));
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
const r = cursor.value as KlineRecord;
if (r.time >= since && r.time <= until) {
results.push({
time: r.time,
open: r.open,
high: r.high,
low: r.low,
close: r.close,
volume: r.volume,
});
}
cursor.continue();
} else {
// Sort ascending by time
results.sort((a, b) => a.time - b.time);
resolve(results);
}
};
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------
// Check if we already have recent data (avoid re-fetching)
// ----------------------------------------------------------------
export async function hasRecentKlines(symbol: string, maxAgeHours = 6): Promise<boolean> {
const db = await openDB();
const lower = symbol.toLowerCase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const idx = store.index("symbol");
const req = idx.openCursor(IDBKeyRange.only(lower), "prev");
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) {
resolve(false);
return;
}
const r = cursor.value as KlineRecord;
const ageHours = (Date.now() - new Date(r.fetchedAt).getTime()) / (1000 * 60 * 60);
resolve(ageHours < maxAgeHours);
};
req.onerror = () => reject(req.error);
});
}
// ----------------------------------------------------------------
// Get date range of stored data for a symbol
// ----------------------------------------------------------------
export async function getKlineRange(symbol: string): Promise<{ oldest: number | null; newest: number | null; count: number }> {
const all = await getKlines(symbol);
if (all.length === 0) return { oldest: null, newest: null, count: 0 };
return {
oldest: all[0].time,
newest: all[all.length - 1].time,
count: all.length,
};
}
// ----------------------------------------------------------------
// Clear all OHLCV data (nuclear option)
// ----------------------------------------------------------------
export async function clearOhlcv(): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

21
src/umami.ts Normal file
View File

@ -0,0 +1,21 @@
type UmamiPayload = Record<string, string | number | boolean | null | undefined>
declare global {
interface Window {
umami?: {
track: (eventName: string, data?: UmamiPayload) => void
identify?: (uniqueId: string, data?: UmamiPayload) => void
}
}
}
export function trackEvent(eventName: string, data?: UmamiPayload) {
if (typeof window === 'undefined') return
if (!window.umami || typeof window.umami.track !== 'function') return
try {
window.umami.track(eventName, data)
} catch {
// Analytics must never break the app.
}
}

6
src/utils/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}

19
vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import path from "path";
import { fileURLToPath } from "url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), viteSingleFile()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});