feat: upcoming economic releases panel on Calendar page

- eco.py: GET /api/eco/upcoming — estimates next release date per series
  from last stored date + frequency + typical publication lag; returns
  status (imminent/due_soon/upcoming/scheduled/overdue/no_data)
- CalendarPage.tsx: UpcomingPanel component in right sidebar showing
  next expected dates, J-N countdown, last value + direction signal;
  color-coded by urgency (orange=cette semaine, yellow=attendu, blue=ce mois)

Dates are approximate (freq + typical lag), not official FRED schedule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 16:01:26 +02:00
parent b058b7297a
commit 915560bc31
2 changed files with 198 additions and 0 deletions

View File

@@ -308,6 +308,103 @@ function BootstrapPanel({ onDone }: { onDone: () => void }) {
)
}
// ── Upcoming releases panel ───────────────────────────────────────────────────
interface UpcomingEvent {
series_id: string
name: string
note: string
category: string
last_release: string | null
last_value: number | null
last_unit: string
last_direction: string | null
next_expected: string | null
days_until: number | null
status: string
}
const STATUS_STYLE: Record<string, string> = {
imminent: 'text-orange-400 border-orange-700/40 bg-orange-900/10',
due_soon: 'text-yellow-400 border-yellow-700/40 bg-yellow-900/10',
upcoming: 'text-blue-400 border-blue-700/30',
scheduled: 'text-slate-500 border-slate-700/30',
overdue: 'text-red-400 border-red-700/30',
no_data: 'text-slate-600 border-slate-700/20',
}
const STATUS_LABEL: Record<string, string> = {
imminent: 'Cette semaine',
due_soon: 'Attendu',
upcoming: 'Ce mois',
scheduled: 'Planifié',
overdue: 'En retard',
no_data: 'Pas en base',
}
function UpcomingPanel() {
const [events, setEvents] = useState<UpcomingEvent[]>([])
useEffect(() => {
fetch('/api/eco/upcoming')
.then(r => r.json())
.then(setEvents)
.catch(() => {})
}, [])
const visible = events.filter(e => e.status !== 'scheduled' || e.days_until !== null && e.days_until < 45)
return (
<div className="card">
<div className="text-xs font-semibold text-slate-300 mb-2 flex items-center gap-1">
<Calendar className="w-3 h-3 text-orange-400" /> Prochaines publications
</div>
{visible.length === 0 ? (
<div className="text-xs text-slate-600 py-2 text-center">Aucune donnée</div>
) : (
<div className="space-y-1.5">
{visible.map(ev => (
<div key={ev.series_id} className={clsx(
'flex items-start justify-between gap-1 px-2 py-1 rounded border text-xs',
STATUS_STYLE[ev.status] ?? STATUS_STYLE.scheduled,
)}>
<div className="min-w-0">
<div className="font-medium text-white truncate">{ev.name}</div>
<div className="text-slate-500">{ev.note}</div>
{ev.last_value !== null && (
<div className="text-slate-500">
Dernier : {ev.last_value.toFixed(ev.last_unit === '%' || ev.last_unit === 'pp' ? 2 : 0)} {ev.last_unit}
{ev.last_direction && ev.last_direction !== 'neutral' && (
<span className={clsx('ml-1', ev.last_direction === 'bullish' ? 'text-emerald-400' : 'text-red-400')}>
{ev.last_direction === 'bullish' ? '▲' : '▼'}
</span>
)}
</div>
)}
</div>
<div className="text-right shrink-0">
<div className={clsx(
'text-xs font-semibold',
STATUS_STYLE[ev.status]?.split(' ')[0],
)}>
{STATUS_LABEL[ev.status]}
</div>
{ev.next_expected && (
<div className="text-slate-600 font-mono">{ev.next_expected.slice(5)}</div>
)}
{ev.days_until !== null && ev.days_until >= 0 && (
<div className="text-slate-600">J{ev.days_until}</div>
)}
</div>
</div>
))}
</div>
)}
<div className="text-xs text-slate-700 mt-2">* Dates estimées d'après fréquence + délai typique</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function CalendarPage() {
@@ -645,6 +742,9 @@ export default function CalendarPage() {
{/* ── Right sidebar ── */}
<div className="col-span-1 space-y-4">
{/* Upcoming releases */}
<UpcomingPanel />
{/* Séries disponibles */}
{ecoStatus && ecoStatus.total > 0 && (
<div className="card">