feat: FF CSV bundled in image + auto-import on startup
- forex_factory_cache.csv moved to backend/ (Docker build context) → available at /app/forex_factory_cache.csv inside container - main.py startup: auto-imports CSV in background thread if ff_calendar empty (idempotent — skips if rows already present) - CSV path candidates: /app/ (Docker) → /tmp/ (upload) → local dev paths - CalendarPage: remove Upload CSV + Import into DB buttons → replaced by auto-import status indicator + Sync Live only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83428
backend/forex_factory_cache.csv
Normal file
83428
backend/forex_factory_cache.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,33 @@ def startup():
|
|||||||
from services.institutional_scheduler import start_institutional_scheduler
|
from services.institutional_scheduler import start_institutional_scheduler
|
||||||
start_institutional_scheduler()
|
start_institutional_scheduler()
|
||||||
|
|
||||||
|
# Auto-import Forex Factory CSV if ff_calendar table is empty
|
||||||
|
import threading
|
||||||
|
def _auto_import_ff():
|
||||||
|
import time, os
|
||||||
|
from services.database import get_conn
|
||||||
|
from routers.eco import _find_csv, _run_ff_import, _ff_import_status
|
||||||
|
try:
|
||||||
|
conn = get_conn()
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM ff_calendar").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
if count > 0:
|
||||||
|
print(f"[Startup] ff_calendar already has {count} rows — skipping auto-import", flush=True)
|
||||||
|
return
|
||||||
|
csv_path = _find_csv()
|
||||||
|
if not csv_path:
|
||||||
|
print("[Startup] forex_factory_cache.csv not found — skipping auto-import", flush=True)
|
||||||
|
return
|
||||||
|
print(f"[Startup] ff_calendar empty — auto-importing {csv_path} …", flush=True)
|
||||||
|
_run_ff_import(csv_path)
|
||||||
|
result = _ff_import_status.get("last_result") or {}
|
||||||
|
inserted = result.get("inserted", 0)
|
||||||
|
print(f"[Startup] FF auto-import done — {inserted} rows inserted", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Startup] FF auto-import error: {e}", flush=True)
|
||||||
|
|
||||||
|
threading.Thread(target=_auto_import_ff, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def shutdown():
|
def shutdown():
|
||||||
|
|||||||
@@ -296,11 +296,12 @@ def upcoming_events() -> List[Dict[str, Any]]:
|
|||||||
_ff_import_status: Dict[str, Any] = {"running": False, "last_result": None}
|
_ff_import_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
_ff_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
_ff_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
|
|
||||||
# CSV may sit at project root (local dev) or in /tmp (uploaded via browser on server)
|
# CSV search order: Docker image (/app), uploaded to /tmp, local dev paths
|
||||||
_FF_CSV_CANDIDATES = [
|
_FF_CSV_CANDIDATES = [
|
||||||
"/tmp/forex_factory_cache.csv",
|
"/app/forex_factory_cache.csv", # Docker (backend/ copied to /app)
|
||||||
os.path.join(os.path.dirname(__file__), "..", "forex_factory_cache.csv"),
|
"/tmp/forex_factory_cache.csv", # browser upload
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..", "forex_factory_cache.csv"),
|
os.path.join(os.path.dirname(__file__), "..", "forex_factory_cache.csv"), # local dev (backend/)
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "forex_factory_cache.csv"), # project root
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, ChangeEvent } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { RefreshCw, Download, ChevronDown, AlertTriangle, Check, Upload } from 'lucide-react'
|
import { RefreshCw, ChevronDown, AlertTriangle, Check } from 'lucide-react'
|
||||||
|
|
||||||
const API = ''
|
const API = ''
|
||||||
|
|
||||||
@@ -130,102 +130,57 @@ function SurpriseChip({ ev }: { ev: FFEvent }) {
|
|||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) {
|
function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) {
|
||||||
const [status, setStatus] = useState<ImportStatus>({ running: false, last_result: null })
|
|
||||||
const [syncResult, setSyncResult] = useState<string | null>(null)
|
const [syncResult, setSyncResult] = useState<string | null>(null)
|
||||||
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
const [importStatus, setImportStatus] = useState<ImportStatus>({ running: false, last_result: null })
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const handleUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
// Poll import status on mount (auto-import may be running in background)
|
||||||
const file = e.target.files?.[0]
|
useEffect(() => {
|
||||||
if (!file) return
|
const poll = async () => {
|
||||||
setUploading(true)
|
const r: ImportStatus = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()).catch(() => ({ running: false, last_result: null }))
|
||||||
setUploadMsg(`Uploading ${(file.size / 1_048_576).toFixed(1)} MB…`)
|
setImportStatus(r)
|
||||||
const form = new FormData()
|
if (r.running) {
|
||||||
form.append('file', file)
|
pollRef.current = setInterval(async () => {
|
||||||
try {
|
const s: ImportStatus = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()).catch(() => r)
|
||||||
const r = await fetch(`${API}/api/eco/ff-upload`, { method: 'POST', body: form }).then(x => x.json())
|
setImportStatus(s)
|
||||||
if (r.error) { setUploadMsg(`Upload error: ${r.error}`); return }
|
if (!s.running) { clearInterval(pollRef.current!); onImported() }
|
||||||
setUploadMsg(`✓ Uploaded ${r.size_mb} MB — click Import to load into DB`)
|
}, 2000)
|
||||||
} catch (err) {
|
|
||||||
setUploadMsg(`Upload failed: ${err}`)
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
if (fileRef.current) fileRef.current.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startImport = async () => {
|
|
||||||
const r = await fetch(`${API}/api/eco/ff-import`, { method: 'POST' })
|
|
||||||
if (!r.ok) {
|
|
||||||
const err = await r.json().catch(() => ({}))
|
|
||||||
setUploadMsg(`Import error: ${err.detail ?? r.status}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pollRef.current = setInterval(async () => {
|
|
||||||
const r = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json())
|
|
||||||
setStatus(r)
|
|
||||||
if (!r.running) {
|
|
||||||
clearInterval(pollRef.current!)
|
|
||||||
onImported()
|
|
||||||
}
|
}
|
||||||
}, 1500)
|
}
|
||||||
}
|
poll()
|
||||||
|
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||||
|
}, [onImported])
|
||||||
|
|
||||||
const startSync = async () => {
|
const startSync = async () => {
|
||||||
setSyncResult('syncing…')
|
setSyncResult('syncing…')
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/api/eco/ff-sync`, { method: 'POST' }).then(x => x.json())
|
await fetch(`${API}/api/eco/ff-sync`, { method: 'POST' })
|
||||||
// Wait for completion
|
await new Promise(res => setTimeout(res, 2500))
|
||||||
await new Promise(res => setTimeout(res, 2000))
|
|
||||||
const s = await fetch(`${API}/api/eco/ff-sync/status`).then(x => x.json())
|
const s = await fetch(`${API}/api/eco/ff-sync/status`).then(x => x.json())
|
||||||
const res = s.last_result || {}
|
const res = s.last_result || {}
|
||||||
const tw = res.thisweek?.events ?? '?'
|
const tw = res.thisweek?.events ?? '?'
|
||||||
const nw = res.nextweek?.events ?? 'n/a'
|
setSyncResult(`✓ ${tw} events synced`)
|
||||||
setSyncResult(`✓ this week: ${tw} events${typeof nw === 'number' ? `, next week: ${nw}` : ''}`)
|
|
||||||
onImported()
|
onImported()
|
||||||
} catch {
|
} catch { setSyncResult('sync error') }
|
||||||
setSyncResult('sync error')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
|
||||||
|
|
||||||
const res = status.last_result
|
|
||||||
const hasData = stats.total > 0
|
const hasData = stats.total > 0
|
||||||
|
const res = importStatus.last_result
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-800 border border-slate-700 rounded-lg p-4 mb-4 flex flex-wrap items-center gap-4 text-sm">
|
<div className="bg-slate-800 border border-slate-700 rounded-lg p-4 mb-4 flex flex-wrap items-center gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-400 text-xs mb-0.5">FF Calendar DB</div>
|
<div className="text-slate-400 text-xs mb-0.5">FF Calendar DB</div>
|
||||||
<div className="font-semibold">
|
<div className="font-semibold">
|
||||||
{hasData
|
{importStatus.running
|
||||||
? <><span className="text-emerald-400">{stats.total.toLocaleString()} events</span> <span className="text-slate-500 text-xs">({stats.earliest?.slice(0,7)} → {stats.latest?.slice(0,7)})</span></>
|
? <span className="text-yellow-400 animate-pulse">Importing CSV… (auto-import on startup)</span>
|
||||||
: <span className="text-slate-500">Not imported yet</span>
|
: hasData
|
||||||
|
? <><span className="text-emerald-400">{stats.total.toLocaleString()} events</span> <span className="text-slate-500 text-xs">({stats.earliest?.slice(0,7)} → {stats.latest?.slice(0,7)})</span></>
|
||||||
|
: <span className="text-slate-400 text-xs">Auto-import will run on next server start (CSV bundled in image)</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload CSV (for server deployment) */}
|
|
||||||
<label className={clsx(
|
|
||||||
'px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 flex items-center gap-1.5 text-white cursor-pointer',
|
|
||||||
uploading && 'opacity-50 cursor-wait'
|
|
||||||
)}>
|
|
||||||
<Upload size={13} />
|
|
||||||
{uploading ? 'Uploading…' : 'Upload CSV'}
|
|
||||||
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleUpload} disabled={uploading} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={startImport}
|
|
||||||
disabled={status.running}
|
|
||||||
className="px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5 text-white"
|
|
||||||
>
|
|
||||||
<Download size={13} />
|
|
||||||
{status.running ? 'Importing…' : 'Import into DB'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={startSync}
|
onClick={startSync}
|
||||||
className="px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 flex items-center gap-1.5 text-white"
|
className="px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 flex items-center gap-1.5 text-white"
|
||||||
@@ -234,18 +189,10 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
Sync Live (this week)
|
Sync Live (this week)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{uploadMsg && (
|
{res && !importStatus.running && (
|
||||||
<span className={clsx('text-xs', uploadMsg.startsWith('✓') ? 'text-emerald-400' : uploadMsg.startsWith('Upload error') || uploadMsg.startsWith('Import error') ? 'text-red-400' : 'text-yellow-400')}>
|
|
||||||
{uploadMsg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{status.running && (
|
|
||||||
<span className="text-yellow-400 text-xs animate-pulse">Importing CSV — this may take 30-60 s…</span>
|
|
||||||
)}
|
|
||||||
{res && !status.running && (
|
|
||||||
res.error
|
res.error
|
||||||
? <span className="text-red-400 text-xs">Error: {res.error}</span>
|
? <span className="text-red-400 text-xs">Import error: {res.error}</span>
|
||||||
: <span className="text-emerald-400 text-xs flex items-center gap-1"><Check size={12}/> {res.inserted?.toLocaleString()} inserted, {res.skipped?.toLocaleString()} skipped</span>
|
: <span className="text-emerald-400 text-xs flex items-center gap-1"><Check size={12}/> {res.inserted?.toLocaleString()} inserted</span>
|
||||||
)}
|
)}
|
||||||
{syncResult && <span className="text-sky-400 text-xs">{syncResult}</span>}
|
{syncResult && <span className="text-sky-400 text-xs">{syncResult}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user