Calendar synchro

This commit is contained in:
OpenSquared
2026-06-28 12:15:25 +02:00
parent 98d6be8212
commit 79d4a9f741
6 changed files with 237 additions and 90 deletions

View File

@@ -131,16 +131,50 @@ function SurpriseChip({ ev }: { ev: FFEvent }) {
// ── Import Panel ──────────────────────────────────────────────────────────────
interface CalSyncStatus {
running: boolean
last_run: string | null
last_result: Record<string, any> | null
source: string | null
next_run: string | null
}
function fmtAgo(iso: string | null): string | null {
if (!iso) return null
const ms = Date.now() - new Date(iso).getTime()
const m = Math.floor(ms / 60000)
if (m < 1) return 'just now'
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
return `${h}h ${m % 60}m ago`
}
function fmtNext(iso: string | null): string | null {
if (!iso) return null
const ms = new Date(iso).getTime() - Date.now()
if (ms <= 0) return 'imminent'
const m = Math.floor(ms / 60000)
if (m < 60) return `in ${m}m`
const h = Math.floor(m / 60)
return `in ${h}h ${m % 60}m`
}
function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) {
const [syncResult, setSyncResult] = useState<string | null>(null)
const [fxsMsg, setFxsMsg] = useState<string | null>(null)
const [fxsSyncing, setFxsSyncing] = useState(false)
const [importStatus, setImportStatus] = useState<ImportStatus>({ running: false, last_result: null })
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fxsPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [calStatus, setCalStatus] = useState<CalSyncStatus>({ running: false, last_run: null, last_result: null, source: null, next_run: null })
const [syncMsg, setSyncMsg] = useState<string | null>(null)
const calPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [teHtmlMsg, setTeHtmlMsg] = useState<string | null>(null)
const [teHtmlBusy, setTeHtmlBusy] = useState(false)
const teHtmlPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fileInputId = useId()
useEffect(() => {
const poll = async () => {
// Check startup auto-import status
const pollImport = async () => {
const r: ImportStatus = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()).catch(() => ({ running: false, last_result: null }))
setImportStatus(r)
if (r.running) {
@@ -151,50 +185,44 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
}, 2000)
}
}
poll()
pollImport()
// Load calendar sync status
fetch(`${API}/api/eco/calendar-sync/status`).then(x => x.json()).then(setCalStatus).catch(() => {})
return () => {
if (pollRef.current) clearInterval(pollRef.current)
if (fxsPollRef.current) clearInterval(fxsPollRef.current)
if (pollRef.current) clearInterval(pollRef.current)
if (calPollRef.current) clearInterval(calPollRef.current)
if (teHtmlPollRef.current) clearInterval(teHtmlPollRef.current)
}
}, [onImported])
const startSync = async () => {
setSyncResult('syncing…')
const startCalendarSync = async () => {
setSyncMsg('syncing…')
setCalStatus(s => ({ ...s, running: true }))
try {
await fetch(`${API}/api/eco/ff-sync`, { method: 'POST' })
await new Promise(res => setTimeout(res, 2500))
const s = await fetch(`${API}/api/eco/ff-sync/status`).then(x => x.json())
const res = s.last_result || {}
setSyncResult(`✓ live: ${res.thisweek?.events ?? '?'} events`)
onImported()
} catch { setSyncResult('sync error') }
}
const startFxsSync = async () => {
setFxsSyncing(true)
setFxsMsg('syncing FXStreet…')
try {
await fetch(`${API}/api/eco/fxs-sync?weeks=6`, { method: 'POST' })
fxsPollRef.current = setInterval(async () => {
const s = await fetch(`${API}/api/eco/fxs-sync/status`).then(x => x.json())
await fetch(`${API}/api/eco/calendar-sync?weeks=8`, { method: 'POST' })
calPollRef.current = setInterval(async () => {
const s: CalSyncStatus = await fetch(`${API}/api/eco/calendar-sync/status`).then(x => x.json())
setCalStatus(s)
if (!s.running) {
clearInterval(fxsPollRef.current!)
setFxsSyncing(false)
clearInterval(calPollRef.current!)
const r = s.last_result || {}
if (r.error) { setFxsMsg(`Error: ${r.error}`); return }
setFxsMsg(`✓ FXStreet: ${r.total_upserted} events (${r.date_from}${r.date_to})`)
if (r.error) {
setSyncMsg(`Erreur: ${r.error}`)
} else if (s.source === 'ff_fallback') {
const tw = r.thisweek?.events ?? '?'
const nw = r.nextweek?.events ?? '?'
setSyncMsg(`✓ FF fallback: ${tw}+${nw} events`)
} else {
setSyncMsg(`✓ FXStreet: ${r.total_upserted} events`)
}
onImported()
}
}, 2000)
} catch { setFxsSyncing(false); setFxsMsg('FXStreet error') }
} catch { setSyncMsg('sync error'); setCalStatus(s => ({ ...s, running: false })) }
}
// TE HTML upload + import
const [teHtmlMsg, setTeHtmlMsg] = useState<string | null>(null)
const [teHtmlBusy, setTeHtmlBusy] = useState(false)
const teHtmlPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fileInputId = useId()
const uploadAndImportTeHtml = async (file: File) => {
setTeHtmlBusy(true)
setTeHtmlMsg('uploading…')
@@ -223,6 +251,7 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
const hasData = stats.total > 0
const res = importStatus.last_result
const srcLabel = calStatus.source === 'ff_fallback' ? 'FF fallback' : calStatus.source === 'fxstreet' ? 'FXStreet' : null
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">
@@ -230,36 +259,32 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
<div className="text-slate-400 text-xs mb-0.5">FF Calendar DB</div>
<div className="font-semibold">
{importStatus.running
? <span className="text-yellow-400 animate-pulse">Importing CSV (auto-import on startup)</span>
? <span className="text-yellow-400 animate-pulse">Importing CSV</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</span>
: <span className="text-slate-400 text-xs">No data yet</span>
}
</div>
{calStatus.last_run && (
<div className="text-xs text-slate-500 mt-0.5">
Last: {fmtAgo(calStatus.last_run)}{srcLabel ? ` via ${srcLabel}` : ''}
{calStatus.next_run && <span className="ml-2">· Next: {fmtNext(calStatus.next_run)}</span>}
</div>
)}
</div>
{/* FF live sync — this week actuals */}
{/* Unified sync button */}
<button
onClick={startSync}
className="px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 flex items-center gap-1.5 text-white"
title="Update this week actuals from faireconomy.media (Forex Factory JSON)"
>
<RefreshCw size={13} />
Sync Live (FF)
</button>
{/* FXStreet — upcoming 6 weeks + forecasts, no API key */}
<button
onClick={startFxsSync}
disabled={fxsSyncing}
onClick={startCalendarSync}
disabled={calStatus.running}
className="px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 flex items-center gap-1.5 text-white"
title="Fetch upcoming 6 weeks with consensus forecasts from FXStreet (no API key required)"
title="Sync economic calendar (FXStreet primary, FF fallback if blocked)"
>
<RefreshCw size={13} className={clsx(fxsSyncing && 'animate-spin')} />
Sync Upcoming (FXStreet)
<RefreshCw size={13} className={clsx(calStatus.running && 'animate-spin')} />
{calStatus.running ? 'Syncing…' : 'Sync Now'}
</button>
{/* TE HTML import — paste calendar_data.txt from tradingeconomics.com */}
{/* TE HTML import */}
<label
htmlFor={fileInputId}
className={clsx(
@@ -287,10 +312,9 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
? <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</span>
)}
{syncResult && <span className="text-sky-400 text-xs">{syncResult}</span>}
{fxsMsg && (
<span className={clsx('text-xs', fxsMsg.startsWith('✓') ? 'text-emerald-400' : fxsMsg.startsWith('Error') ? 'text-red-400' : 'text-yellow-400')}>
{fxsMsg}
{syncMsg && (
<span className={clsx('text-xs', syncMsg.startsWith('✓') ? 'text-emerald-400' : syncMsg.startsWith('Err') ? 'text-red-400' : 'text-yellow-400')}>
{syncMsg}
</span>
)}
{teHtmlMsg && (

View File

@@ -351,6 +351,7 @@ export default function Config() {
const [newsapiKey, setNewsapiKey] = useState('')
const [eiaKey, setEiaKey] = useState('')
const [fredKey, setFredKey] = useState('')
const [calendarRefreshH, setCalendarRefreshH] = useState(6)
const [showKeys, setShowKeys] = useState(false)
const [savedMsg, setSavedMsg] = useState('')
const [configTab, setConfigTab] = useState<'ia' | 'cycle' | 'sources' | 'options' | 'profiles' | 'data'>('ia')
@@ -472,6 +473,13 @@ export default function Config() {
}
}, [optionsGateData])
useEffect(() => {
if (config?.calendar_refresh_h) {
const h = parseFloat(config.calendar_refresh_h)
if (!isNaN(h)) setCalendarRefreshH(h)
}
}, [config])
useEffect(() => {
if (techIndicatorsData) {
setTechEnabled(techIndicatorsData.tech_indicators_enabled ?? true)
@@ -1095,6 +1103,39 @@ export default function Config() {
</button>
</div>
</div>
{/* Calendar auto-sync frequency */}
<div className="card">
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-3">
<RefreshCw className="w-4 h-4 text-indigo-400" /> Calendar Auto-Sync
</h2>
<div className="text-xs text-slate-400 mb-3">
Interval between automatic economic calendar syncs (FXStreet primary, Forex Factory fallback).
</div>
<div className="flex items-center gap-3">
<label className="text-xs text-slate-500 whitespace-nowrap">Refresh every</label>
<div className="flex gap-1">
{[2, 4, 6, 12, 24].map(h => (
<button key={h} onClick={() => setCalendarRefreshH(h)}
className={clsx('px-3 py-1.5 rounded text-xs transition-colors', {
'bg-indigo-700 text-white': calendarRefreshH === h,
'bg-dark-700 text-slate-400 hover:text-slate-200': calendarRefreshH !== h,
})}>
{h}h
</button>
))}
</div>
<button
onClick={() => updateApiKeys({ calendar_refresh_h: String(calendarRefreshH) }, {
onSuccess: () => { setSavedMsg('Calendar refresh saved'); setTimeout(() => setSavedMsg(''), 2000) }
})}
disabled={savingKeys}
className="flex items-center gap-1.5 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-40 text-white px-3 py-1.5 rounded text-xs font-semibold">
<Save className="w-3.5 h-3.5" />
{savingKeys ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)}