diff --git a/package-lock.json b/package-lock.json index 31fe699..b8befc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5", + "typescript": "^5.9.3", "webpack-bundle-analyzer": "^4.9.0" } }, diff --git a/package.json b/package.json index b1e869e..1323746 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5", + "typescript": "^5.9.3", "webpack-bundle-analyzer": "^4.9.0" } } diff --git a/src/app/components/providers/HistoryProvider.tsx b/src/app/components/providers/HistoryProvider.tsx new file mode 100644 index 0000000..8c2b64a --- /dev/null +++ b/src/app/components/providers/HistoryProvider.tsx @@ -0,0 +1,15 @@ +'use client' + +import { ReactNode } from 'react' +import { useHistoricalSync } from '@/app/hooks/useHistoricalSync' + +interface HistoryProviderProps { + children: ReactNode + enabled?: boolean +} + +export function HistoryProvider({ children, enabled = true }: HistoryProviderProps) { + useHistoricalSync(enabled) + + return <>{children} +} diff --git a/src/app/hooks/useHistoricalData.ts b/src/app/hooks/useHistoricalData.ts new file mode 100644 index 0000000..250557d --- /dev/null +++ b/src/app/hooks/useHistoricalData.ts @@ -0,0 +1,122 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { getDataPoints, getLatestDataPoints, getAllAssetPairs } from '@/lib/indexeddb' +import type { HistoricalDataPoint } from '@/types' + +interface UseHistoricalDataOptions { + fromTimestamp?: number + toTimestamp?: number + limit?: number +} + +export function useHistoricalData( + assetPair: string | null, + options?: UseHistoricalDataOptions, +) { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshKey, setRefreshKey] = useState(0) + + useEffect(() => { + let cancelled = false + + async function load() { + const pair = assetPair + if (!pair) { + setData([]) + setLoading(false) + setError(null) + return + } + + setLoading(true) + setError(null) + + try { + const points = await getDataPoints(pair, { + fromTimestamp: options?.fromTimestamp, + toTimestamp: options?.toTimestamp, + limit: options?.limit ?? 500, + }) + if (!cancelled) { + setData(points) + setLoading(false) + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load historical data') + setLoading(false) + } + } + } + + load() + return () => { cancelled = true } + }, [assetPair, options?.fromTimestamp, options?.toTimestamp, options?.limit, refreshKey]) + + const refresh = useCallback(() => setRefreshKey(k => k + 1), []) + + return { data, loading, error, refresh } +} + +export function useLatestHistoricalData( + assetPair: string | null, + count: number = 50, +) { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + + async function load() { + const pair = assetPair + if (!pair) { + setData([]) + setLoading(false) + return + } + + try { + const points = await getLatestDataPoints(pair, count) + if (!cancelled) setData(points) + } catch { + if (!cancelled) setData([]) + } finally { + if (!cancelled) setLoading(false) + } + } + + load() + return () => { cancelled = true } + }, [assetPair, count]) + + return { data, loading } +} + +export function useHistoricalAssetPairs() { + const [pairs, setPairs] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + + async function load() { + try { + const result = await getAllAssetPairs() + if (!cancelled) setPairs(result) + } catch { + if (!cancelled) setPairs([]) + } finally { + if (!cancelled) setLoading(false) + } + } + + load() + return () => { cancelled = true } + }, []) + + return { pairs, loading } +} diff --git a/src/app/hooks/useHistoricalSync.ts b/src/app/hooks/useHistoricalSync.ts new file mode 100644 index 0000000..6cc865a --- /dev/null +++ b/src/app/hooks/useHistoricalSync.ts @@ -0,0 +1,72 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { storeDataPoints, pruneDataPoints } from '@/lib/indexeddb' +import { useSocketData } from '@/app/components/providers/SocketProvider' + +const FLUSH_INTERVAL_MS = 10_000 +const PRUNE_INTERVAL_MS = 300_000 +const MAX_BATCH_SIZE = 50 + +export function useHistoricalSync(enabled: boolean = true) { + const { lastUpdate } = useSocketData() + const batchRef = useRef>([]) + const lastPruneRef = useRef(0) + const lastDataRef = useRef(null) + + useEffect(() => { + if (!enabled || !lastUpdate) return + + const key = `${lastUpdate.assetPair}_${lastUpdate.timestamp}` + if (key === lastDataRef.current) return + lastDataRef.current = key + + batchRef.current.push({ + assetPair: lastUpdate.assetPair, + timestamp: lastUpdate.timestamp, + price: lastUpdate.price, + decimals: lastUpdate.decimals, + source: lastUpdate.source, + confidenceScore: lastUpdate.confidenceScore, + }) + + if (batchRef.current.length >= MAX_BATCH_SIZE) { + const batch = batchRef.current.splice(0, MAX_BATCH_SIZE) + storeDataPoints(batch).catch(() => {}) + } + }, [lastUpdate, enabled]) + + useEffect(() => { + if (!enabled) return + + const flushTimer = setInterval(() => { + if (batchRef.current.length > 0) { + const batch = batchRef.current.splice(0) + storeDataPoints(batch).catch(() => {}) + } + }, FLUSH_INTERVAL_MS) + + return () => clearInterval(flushTimer) + }, [enabled]) + + useEffect(() => { + if (!enabled) return + + const pruneTimer = setInterval(() => { + const now = Date.now() + if (now - lastPruneRef.current >= PRUNE_INTERVAL_MS) { + lastPruneRef.current = now + pruneDataPoints().catch(() => {}) + } + }, PRUNE_INTERVAL_MS) + + return () => clearInterval(pruneTimer) + }, [enabled]) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e15f4f2..b0b83c1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { UserProvider } from "./components/providers/UserProvider"; import { QueryProvider } from "./components/providers/QueryProvider"; import Script from "next/script"; import {SocketProvider} from "./components/providers/SocketProvider"; +import { HistoryProvider } from "./components/providers/HistoryProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -74,9 +75,13 @@ export default function RootLayout({ > - - {children} - + + + + {children} + + + diff --git a/src/lib/indexeddb.ts b/src/lib/indexeddb.ts new file mode 100644 index 0000000..5e24a20 --- /dev/null +++ b/src/lib/indexeddb.ts @@ -0,0 +1,198 @@ +import type { HistoricalDataPoint } from '@/types' + +const DB_NAME = 'stellarflow-history' +const DB_VERSION = 1 +const STORE_NAME = 'priceHistory' +const MAX_RETENTION_DAYS = 7 + +function getKey(point: HistoricalDataPoint): string { + return `${point.assetPair}_${point.timestamp}` +} + +let dbPromise: Promise | null = null + +function openDB(): Promise { + if (dbPromise) return dbPromise + + dbPromise = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB is not available')) + return + } + + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }) + store.createIndex('assetPair', 'assetPair', { unique: false }) + store.createIndex('timestamp', 'timestamp', { unique: false }) + store.createIndex('assetTimestamp', ['assetPair', 'timestamp'], { unique: false }) + } + } + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + + return dbPromise +} + +export async function storeDataPoint(point: HistoricalDataPoint): Promise { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + const id = getKey(point) + const entry = { ...point, id } + + const request = store.put(entry) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) +} + +export async function storeDataPoints(points: HistoricalDataPoint[]): Promise { + if (points.length === 0) return + + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + + for (const point of points) { + const id = getKey(point) + store.put({ ...point, id }) + } + + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +export async function getDataPoints( + assetPair: string, + options?: { + fromTimestamp?: number + toTimestamp?: number + limit?: number + }, +): Promise { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const index = store.index('assetTimestamp') + const range = IDBKeyRange.bound( + [assetPair, options?.fromTimestamp ?? 0], + [assetPair, options?.toTimestamp ?? Date.now()], + ) + const limit = options?.limit ?? 500 + + const results: HistoricalDataPoint[] = [] + const request = index.openCursor(range, 'prev') + + request.onsuccess = () => { + const cursor = request.result + if (cursor && results.length < limit) { + results.push(cursor.value as HistoricalDataPoint) + cursor.continue() + } else { + resolve(results.sort((a, b) => a.timestamp - b.timestamp)) + } + } + request.onerror = () => reject(request.error) + }) +} + +export async function getLatestDataPoints( + assetPair: string, + count: number = 50, +): Promise { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const index = store.index('assetTimestamp') + const range = IDBKeyRange.bound( + [assetPair, 0], + [assetPair, Date.now()], + ) + const results: HistoricalDataPoint[] = [] + const request = index.openCursor(range, 'prev') + + request.onsuccess = () => { + const cursor = request.result + if (cursor && results.length < count) { + results.push(cursor.value as HistoricalDataPoint) + cursor.continue() + } else { + resolve(results.sort((a, b) => a.timestamp - b.timestamp)) + } + } + request.onerror = () => reject(request.error) + }) +} + +export async function getAllAssetPairs(): Promise { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const store = tx.objectStore(STORE_NAME) + const index = store.index('assetPair') + const assetPairs = new Set() + const request = index.openCursor() + + request.onsuccess = () => { + const cursor = request.result + if (cursor) { + assetPairs.add((cursor.value as HistoricalDataPoint).assetPair) + cursor.continue() + } else { + resolve(Array.from(assetPairs).sort()) + } + } + request.onerror = () => reject(request.error) + }) +} + +export async function pruneDataPoints( + retentionDays: number = MAX_RETENTION_DAYS, +): Promise { + const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + const index = store.index('timestamp') + const range = IDBKeyRange.upperBound(cutoff) + let deletedCount = 0 + const request = index.openCursor(range) + + request.onsuccess = () => { + const cursor = request.result + if (cursor) { + store.delete(cursor.primaryKey) + deletedCount++ + cursor.continue() + } + } + + tx.oncomplete = () => resolve(deletedCount) + tx.onerror = () => reject(tx.error) + }) +} + +export async function clearAllData(): Promise { + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + const store = tx.objectStore(STORE_NAME) + store.clear() + + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} diff --git a/src/types/index.ts b/src/types/index.ts index 8a47dff..a4ecec1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -34,3 +34,12 @@ export interface PriceData { confidenceScore: number; metadata?: unknown; // Using unknown as per guardrail (no 'any') } + +export interface HistoricalDataPoint { + assetPair: string; + timestamp: number; + price: number; + decimals: number; + source: string; + confidenceScore: number; +}