From edfa90c9b7f1d7120af63a0eebbe203f0ee6db70 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 24 Jun 2026 21:01:01 +0200 Subject: [PATCH] feat: vertical 3-column timeline + MA regime bootstrap engine - TimelineVertical: replace horizontal frise with Y=time vertical layout, 3 columns (Long/Medium/Short), auto-scroll to selected date, sub-columns for overlapping events, today/selected-date horizontal lines - ma_analyzer.py: detect MA50/MA200 crossovers + MA100 slope changes + MA20 direction swings on EUR/USD, Brent, Gold, S&P500, US10Y (5y history) with 5-bar confirmation, dedup, GPT-4o-mini enrichment, idempotent DB save - POST /api/timeline/bootstrap-ma endpoint to trigger analysis - Bootstrap MA button in Timeline page with loading state + result count Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/timeline.py | 12 + backend/services/ma_analyzer.py | 570 +++++++++++++++++++ frontend/src/components/TimelineVertical.tsx | 308 ++++++++++ frontend/src/pages/Timeline.tsx | 36 +- 4 files changed, 922 insertions(+), 4 deletions(-) create mode 100644 backend/services/ma_analyzer.py create mode 100644 frontend/src/components/TimelineVertical.tsx diff --git a/backend/routers/timeline.py b/backend/routers/timeline.py index 26dece8..5fd3bd8 100644 --- a/backend/routers/timeline.py +++ b/backend/routers/timeline.py @@ -209,3 +209,15 @@ def bootstrap_events() -> Dict[str, Any]: from services.timeline_service import bootstrap_events as _bootstrap count = _bootstrap() return {"seeded": count, "status": "ok" if count > 0 else "already_seeded"} + + +@router.post("/bootstrap-ma") +async def bootstrap_ma() -> Dict[str, Any]: + """Detect MA ruptures on 5 key underlyings and generate historical market events via GPT.""" + from services.ma_analyzer import bootstrap_ma_events + try: + result = await bootstrap_ma_events() + return {**result, "status": "ok"} + except Exception as e: + logger.error(f"[Timeline] bootstrap-ma failed: {e}") + raise HTTPException(500, str(e)) diff --git a/backend/services/ma_analyzer.py b/backend/services/ma_analyzer.py new file mode 100644 index 0000000..493afe1 --- /dev/null +++ b/backend/services/ma_analyzer.py @@ -0,0 +1,570 @@ +""" +MA Analyzer — Bootstrap historical market periods from price-based MA signals. + +Fetches 5y daily prices for 5 key underlyings, detects MA ruptures (golden/death +cross, MA100 slope changes, MA20 direction changes), enriches candidates with +GPT-4o-mini, and persists them via save_market_event(). +""" +import json +import logging +import os +import time +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +import yfinance as yf + +from services.database import count_market_events, save_market_event + +logger = logging.getLogger(__name__) + +UNDERLYINGS = [ + {"ticker": "EURUSD=X", "name": "EUR/USD", "category": "market", "assets": ["EUR/USD", "USD", "EUR"]}, + {"ticker": "BZ=F", "name": "Brent Crude", "category": "market", "assets": ["CL", "BZ", "Oil"]}, + {"ticker": "GC=F", "name": "Gold", "category": "market", "assets": ["GC", "Gold", "GLD"]}, + {"ticker": "^GSPC", "name": "S&P 500", "category": "market", "assets": ["SPX", "SPY", "QQQ"]}, + {"ticker": "^TNX", "name": "US 10Y Yield", "category": "market", "assets": ["TNX", "TLT", "Bonds"]}, +] + +MA_PERIODS = [20, 50, 100, 200] +CROSS_CONFIRM_DAYS = 5 +SLOPE_WINDOW = 10 +DEDUP_DAYS = 30 +MAX_CANDIDATES = 80 +GPT_BATCH_SIZE = 5 + + +def _fetch_prices() -> Dict[str, pd.Series]: + """Download 5y daily close prices for all underlyings. Returns ticker → pd.Series.""" + tickers = [u["ticker"] for u in UNDERLYINGS] + prices: Dict[str, pd.Series] = {} + + try: + raw = yf.download(tickers, period="5y", interval="1d", progress=False, auto_adjust=True) + if raw.empty: + raise ValueError("Empty download result") + close = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw + for ticker in tickers: + if ticker in close.columns: + s = close[ticker].dropna() + if not s.empty: + prices[ticker] = s + logger.info(f"[MA] {ticker}: {len(s)} bars") + except Exception as e: + logger.warning(f"[MA] Batch download failed ({e}), falling back to individual") + for ticker in tickers: + try: + df = yf.download(ticker, period="5y", interval="1d", progress=False, auto_adjust=True) + if df.empty: + continue + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.get_level_values(0) + s = df["Close"].dropna() + if not s.empty: + prices[ticker] = s + logger.info(f"[MA] {ticker}: {len(s)} bars") + except Exception as e2: + logger.error(f"[MA] Failed to fetch {ticker}: {e2}") + + return prices + + +def _compute_mas(prices: pd.Series) -> pd.DataFrame: + """Return DataFrame with Close + MA20/50/100/200 columns.""" + df = prices.to_frame(name="Close") + for p in MA_PERIODS: + df[f"MA{p}"] = df["Close"].rolling(p).mean() + return df + + +def _date_str(ts) -> str: + if hasattr(ts, "strftime"): + return ts.strftime("%Y-%m-%d") + return str(ts)[:10] + + +def _price_move_pct(df: pd.DataFrame, start_idx: int, end_idx: int) -> float: + """Absolute % move of Close between two integer iloc positions.""" + try: + p0 = float(df["Close"].iloc[start_idx]) + p1 = float(df["Close"].iloc[end_idx]) + if p0 == 0: + return 0.0 + return abs((p1 - p0) / p0 * 100) + except Exception: + return 0.0 + + +def _detect_crossovers(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]: + """Detect MA50/MA200 golden cross and death cross with 5-day confirmation.""" + candidates = [] + ma50 = df["MA50"] + ma200 = df["MA200"] + + valid = df.dropna(subset=["MA50", "MA200"]) + if len(valid) < CROSS_CONFIRM_DAYS + 2: + return candidates + + idx_list = valid.index.tolist() + + i = 1 + while i < len(idx_list) - CROSS_CONFIRM_DAYS: + prev_i = idx_list[i - 1] + curr_i = idx_list[i] + + prev_above = ma50[prev_i] > ma200[prev_i] + curr_above = ma50[curr_i] > ma200[curr_i] + + if prev_above == curr_above: + i += 1 + continue + + is_golden = curr_above # MA50 just crossed above MA200 + + # Confirm: next CROSS_CONFIRM_DAYS all maintain the new relationship + confirm_ok = True + for k in range(1, CROSS_CONFIRM_DAYS + 1): + if i + k >= len(idx_list): + confirm_ok = False + break + future_i = idx_list[i + k] + if is_golden: + if not (ma50[future_i] > ma200[future_i]): + confirm_ok = False + break + else: + if not (ma50[future_i] < ma200[future_i]): + confirm_ok = False + break + + if confirm_ok: + # start_date = date of the confirming bar (i + CROSS_CONFIRM_DAYS) + confirmed_i = idx_list[i + CROSS_CONFIRM_DAYS] + start_iloc = valid.index.get_loc(confirmed_i) + + event_type = "golden_cross" if is_golden else "death_cross" + name_prefix = "Golden Cross" if is_golden else "Death Cross" + candidates.append({ + "_type": event_type, + "_underlying": underlying, + "_start_iloc": start_iloc, + "start_date": _date_str(confirmed_i), + "end_date": None, + "level": "medium", + "category": "market", + "_move_magnitude": _price_move_pct(df, max(0, start_iloc - 20), start_iloc), + "_name_hint": f"{underlying['name']} {name_prefix} MA50/MA200", + }) + i += CROSS_CONFIRM_DAYS + 1 + else: + i += 1 + + return candidates + + +def _slope(series: pd.Series, window: int = SLOPE_WINDOW) -> pd.Series: + """Percentage slope over window: (val[t] - val[t-window]) / val[t-window] * 100.""" + return (series - series.shift(window)) / series.shift(window) * 100 + + +def _detect_ma100_slope_changes(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]: + """Detect MA100 direction reversals (slope sign flips sustained over 5+ bars).""" + candidates = [] + valid = df.dropna(subset=["MA100"]) + if len(valid) < SLOPE_WINDOW + 10: + return candidates + + slope = _slope(valid["MA100"], SLOPE_WINDOW) + sign = np.sign(slope) + + idx_list = valid.index.tolist() + i = 1 + while i < len(idx_list) - 5: + prev_sign = sign[idx_list[i - 1]] + curr_sign = sign[idx_list[i]] + + if prev_sign == 0 or curr_sign == 0 or prev_sign == curr_sign: + i += 1 + continue + + # Check 5-bar confirmation + confirm_ok = all( + sign[idx_list[i + k]] == curr_sign + for k in range(1, 6) + if i + k < len(idx_list) + ) + if not confirm_ok: + i += 1 + continue + + start_idx = valid.index.get_loc(idx_list[i]) + + # Find end: next sign flip + end_date = None + duration_days = 0 + for j in range(i + 1, len(idx_list)): + if sign[idx_list[j]] == -curr_sign: + end_date = _date_str(idx_list[j]) + duration_days = ( + datetime.strptime(end_date, "%Y-%m-%d") - + datetime.strptime(_date_str(idx_list[i]), "%Y-%m-%d") + ).days + break + + direction = "Bullish" if curr_sign > 0 else "Bearish" + level = "long" if duration_days > 90 or end_date is None else "medium" + + candidates.append({ + "_type": "ma100_slope_change", + "_underlying": underlying, + "_start_iloc": start_idx, + "start_date": _date_str(idx_list[i]), + "end_date": end_date, + "level": level, + "category": "market", + "_move_magnitude": abs(float(slope[idx_list[i]])), + "_name_hint": f"{underlying['name']} MA100 {direction} Trend", + }) + + # Skip to next potential flip zone + if end_date: + while i < len(idx_list) and _date_str(idx_list[i]) < end_date: + i += 1 + else: + break + + return candidates + + +def _detect_ma20_direction_changes(df: pd.DataFrame, underlying: Dict[str, Any]) -> List[Dict[str, Any]]: + """Detect strong MA20 direction changes (> 2% in 10 days).""" + candidates = [] + valid = df.dropna(subset=["MA20"]) + if len(valid) < SLOPE_WINDOW + 10: + return candidates + + slope = _slope(valid["MA20"], SLOPE_WINDOW) + THRESHOLD = 2.0 + + idx_list = valid.index.tolist() + last_added_date = None + + for i in range(1, len(idx_list) - 5): + prev_slope = float(slope[idx_list[i - 1]]) + curr_slope = float(slope[idx_list[i]]) + + if abs(curr_slope) < THRESHOLD: + continue + if np.sign(prev_slope) == np.sign(curr_slope): + continue + + start_date = _date_str(idx_list[i]) + + # Dedup: skip if too close to last added + if last_added_date: + gap = ( + datetime.strptime(start_date, "%Y-%m-%d") - + datetime.strptime(last_added_date, "%Y-%m-%d") + ).days + if gap < DEDUP_DAYS: + continue + + # Find end: slope returns to < 0.5 or reverses + end_date = None + duration_days = 0 + for j in range(i + 1, len(idx_list)): + future_slope = float(slope[idx_list[j]]) + if abs(future_slope) < 0.5 or np.sign(future_slope) != np.sign(curr_slope): + end_date = _date_str(idx_list[j]) + duration_days = ( + datetime.strptime(end_date, "%Y-%m-%d") - + datetime.strptime(start_date, "%Y-%m-%d") + ).days + break + + direction = "Bullish" if curr_slope > 0 else "Bearish" + level = "short" if (end_date and duration_days < 30) else "medium" + + candidates.append({ + "_type": "ma20_direction_change", + "_underlying": underlying, + "_start_iloc": valid.index.get_loc(idx_list[i]), + "start_date": start_date, + "end_date": end_date, + "level": level, + "category": "market", + "_move_magnitude": abs(curr_slope), + "_name_hint": f"{underlying['name']} MA20 {direction} Swing", + }) + last_added_date = start_date + + return candidates + + +def _dedup_candidates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Remove near-duplicate events (same underlying + type, dates < 30 days apart). + Keep the one with the largest _move_magnitude.""" + kept: List[Dict[str, Any]] = [] + for ev in candidates: + ticker = ev["_underlying"]["ticker"] + ev_type = ev["_type"] + ev_start = ev["start_date"] + + duplicate_of = None + for k in kept: + if k["_underlying"]["ticker"] != ticker: + continue + if k["_type"] != ev_type: + continue + gap = abs( + (datetime.strptime(ev_start, "%Y-%m-%d") - + datetime.strptime(k["start_date"], "%Y-%m-%d")).days + ) + if gap < DEDUP_DAYS: + duplicate_of = k + break + + if duplicate_of is None: + kept.append(ev) + elif ev.get("_move_magnitude", 0) > duplicate_of.get("_move_magnitude", 0): + kept.remove(duplicate_of) + kept.append(ev) + + return kept + + +def _assign_end_dates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """For events without an end_date, set it to the next same-type event on same underlying.""" + for i, ev in enumerate(candidates): + if ev.get("end_date"): + continue + ticker = ev["_underlying"]["ticker"] + ev_type = ev["_type"] + ev_start = ev["start_date"] + + # Find the next event of same type/underlying that comes after + next_start = None + for j, other in enumerate(candidates): + if i == j: + continue + if other["_underlying"]["ticker"] != ticker: + continue + if other["_type"] != ev_type: + continue + if other["start_date"] > ev_start: + if next_start is None or other["start_date"] < next_start: + next_start = other["start_date"] + + ev["end_date"] = next_start # None if it's the last (ongoing) + + return candidates + + +def _build_gpt_prompt(batch: List[Dict[str, Any]], df_map: Dict[str, pd.DataFrame]) -> str: + items = [] + for i, ev in enumerate(batch): + underlying = ev["_underlying"] + ticker = underlying["ticker"] + df = df_map.get(ticker) + start_price = end_price = None + if df is not None: + try: + start_idx = df.index.searchsorted(pd.Timestamp(ev["start_date"])) + start_price = float(df["Close"].iloc[min(start_idx, len(df) - 1)]) + except Exception: + pass + if ev.get("end_date"): + try: + end_idx = df.index.searchsorted(pd.Timestamp(ev["end_date"])) + end_price = float(df["Close"].iloc[min(end_idx, len(df) - 1)]) + except Exception: + pass + + items.append({ + "index": i, + "underlying": underlying["name"], + "type": ev["_type"], + "start_date": ev["start_date"], + "end_date": ev.get("end_date"), + "level": ev["level"], + "name_hint": ev["_name_hint"], + "price_start": round(start_price, 4) if start_price else None, + "price_end": round(end_price, 4) if end_price else None, + }) + + return f"""You are a senior macro analyst specializing in technical market regimes. +Analyze these {len(batch)} market events detected via Moving Average signals. +For each, return a JSON object with: +- index: (same as input) +- name: concise English event name (ex: "S&P 500 Golden Cross Bull Trend") +- description: 2-3 sentences explaining market context and significance +- market_impact: 1 sentence on trading/volatility impact +- affected_assets: list of 3-6 ticker symbols most affected +- impact_score: float 0.0-1.0 (0=minor technical, 1=major structural) + +Events to analyze: +{json.dumps(items, indent=2)} + +Return a JSON array of {len(batch)} objects, one per input event, in the same order. +Array only, no wrapping key.""" + + +def _enrich_with_gpt(candidates: List[Dict[str, Any]], df_map: Dict[str, pd.DataFrame]) -> List[Dict[str, Any]]: + """Call GPT-4o-mini in batches of GPT_BATCH_SIZE to enrich candidates.""" + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + logger.warning("[MA] No OPENAI_API_KEY — skipping GPT enrichment") + for ev in candidates: + ev["name"] = ev["_name_hint"] + ev["description"] = f"MA signal detected on {ev['_underlying']['name']}." + ev["market_impact"] = "Technical price action signal." + ev["affected_assets"] = ev["_underlying"]["assets"] + ev["impact_score"] = 0.4 + return candidates + + from openai import OpenAI + client = OpenAI(api_key=api_key) + + for batch_start in range(0, len(candidates), GPT_BATCH_SIZE): + batch = candidates[batch_start: batch_start + GPT_BATCH_SIZE] + try: + prompt = _build_gpt_prompt(batch, df_map) + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + temperature=0.3, + max_tokens=1200, + ) + raw = response.choices[0].message.content.strip() + # Strip markdown code block if present + if raw.startswith("```"): + raw = raw.split("```")[1] + if raw.startswith("json"): + raw = raw[4:] + results = json.loads(raw) + for item in results: + idx = item.get("index") + if idx is not None and 0 <= idx < len(batch): + ev = batch[idx] + ev["name"] = item.get("name", ev["_name_hint"]) + ev["description"] = item.get("description", "") + ev["market_impact"] = item.get("market_impact", "") + ev["affected_assets"] = item.get("affected_assets", ev["_underlying"]["assets"]) + ev["impact_score"] = float(item.get("impact_score", 0.4)) + except Exception as e: + logger.error(f"[MA] GPT batch {batch_start // GPT_BATCH_SIZE + 1} failed: {e}") + for ev in batch: + if "name" not in ev: + ev["name"] = ev["_name_hint"] + ev["description"] = f"MA signal on {ev['_underlying']['name']}." + ev["market_impact"] = "Technical signal." + ev["affected_assets"] = ev["_underlying"]["assets"] + ev["impact_score"] = 0.4 + + time.sleep(1) + + # Ensure all events have required fields + for ev in candidates: + if "name" not in ev: + ev["name"] = ev["_name_hint"] + ev["description"] = f"MA signal on {ev['_underlying']['name']}." + ev["market_impact"] = "Technical signal." + ev["affected_assets"] = ev["_underlying"]["assets"] + ev["impact_score"] = 0.4 + + return candidates + + +def _to_event_dict(ev: Dict[str, Any]) -> Dict[str, Any]: + """Convert internal candidate to the format expected by save_market_event.""" + return { + "name": ev["name"], + "start_date": ev["start_date"], + "end_date": ev.get("end_date"), + "level": ev["level"], + "category": ev.get("category", "market"), + "description": ev.get("description", ""), + "market_impact": ev.get("market_impact", ""), + "affected_assets": ev.get("affected_assets", []), + "impact_score": ev.get("impact_score", 0.4), + "absorption_pct": None, + "relevant_indicators": [], + } + + +async def bootstrap_ma_events() -> Dict[str, Any]: + """ + Main entrypoint. Detects MA ruptures, enriches with GPT, saves to DB. + Idempotent: skips if DB already has > 100 events. + Returns {"detected": N, "saved": N, "skipped": N}. + """ + existing = count_market_events() + if existing > 100: + logger.info(f"[MA] DB already has {existing} events — skipping bootstrap") + return {"detected": 0, "saved": 0, "skipped": existing} + + logger.info("[MA] Fetching price data...") + prices = _fetch_prices() + if not prices: + logger.error("[MA] No price data fetched — aborting") + return {"detected": 0, "saved": 0, "skipped": 0} + + df_map: Dict[str, pd.DataFrame] = {} + all_candidates: List[Dict[str, Any]] = [] + + for underlying in UNDERLYINGS: + ticker = underlying["ticker"] + if ticker not in prices: + logger.warning(f"[MA] No data for {ticker}, skipping") + continue + + try: + df = _compute_mas(prices[ticker]) + df_map[ticker] = df + + cross_evs = _detect_crossovers(df, underlying) + slope_evs = _detect_ma100_slope_changes(df, underlying) + swing_evs = _detect_ma20_direction_changes(df, underlying) + + logger.info( + f"[MA] {underlying['name']}: crossovers={len(cross_evs)}, " + f"slope={len(slope_evs)}, swing={len(swing_evs)}" + ) + all_candidates.extend(cross_evs + slope_evs + swing_evs) + except Exception as e: + logger.error(f"[MA] Detection failed for {underlying['name']}: {e}") + + logger.info(f"[MA] Raw candidates: {len(all_candidates)}") + + # Dedup + assign end dates + candidates = _dedup_candidates(all_candidates) + candidates = _assign_end_dates(candidates) + + # Sort by start_date ascending for GPT context coherence + candidates.sort(key=lambda x: x["start_date"]) + + # Cap at MAX_CANDIDATES (keep most significant by _move_magnitude) + if len(candidates) > MAX_CANDIDATES: + candidates.sort(key=lambda x: x.get("_move_magnitude", 0), reverse=True) + candidates = candidates[:MAX_CANDIDATES] + candidates.sort(key=lambda x: x["start_date"]) + + detected = len(candidates) + logger.info(f"[MA] After dedup/cap: {detected} candidates — enriching with GPT...") + + candidates = _enrich_with_gpt(candidates, df_map) + + saved = 0 + skipped_save = 0 + for ev in candidates: + try: + save_market_event(_to_event_dict(ev)) + saved += 1 + except Exception as e: + logger.error(f"[MA] Failed to save '{ev.get('name', '?')}': {e}") + skipped_save += 1 + + logger.info(f"[MA] Bootstrap complete: detected={detected} saved={saved} skipped={skipped_save}") + return {"detected": detected, "saved": saved, "skipped": skipped_save} diff --git a/frontend/src/components/TimelineVertical.tsx b/frontend/src/components/TimelineVertical.tsx new file mode 100644 index 0000000..743422f --- /dev/null +++ b/frontend/src/components/TimelineVertical.tsx @@ -0,0 +1,308 @@ +import { useRef, useEffect, useMemo } from 'react' + +interface MarketEvent { + id: number + name: string + start_date: string + end_date: string | null + level: 'long' | 'medium' | 'short' + category: string + impact_score: number +} + +interface Props { + events: MarketEvent[] + refDate: string + onDateChange: (d: string) => void +} + +const SCALE = 1.8 // px per day +const MARGIN_LEFT = 52 // year label area +const LEVEL_W = 190 // fixed px per level column +const SUBCOL_GAP = 2 +const COL_GAP = 8 +const CONTAINER_H = 560 +const MIN_EV_H = 20 +const FRISE_START = '2020-02-01' +const LEVEL_ORDER = ['long', 'medium', 'short'] as const + +type Level = typeof LEVEL_ORDER[number] + +const COLORS: Record = { + long: { bg: '#2e1065', border: '#7c3aed', text: '#ede9fe', track: 'rgba(109,40,217,0.07)' }, + medium: { bg: '#1e3a5f', border: '#3b82f6', text: '#dbeafe', track: 'rgba(59,130,246,0.07)' }, + short: { bg: '#064e3b', border: '#10b981', text: '#d1fae5', track: 'rgba(16,185,129,0.07)' }, +} + +const LEVEL_LABELS: Record = { + long: 'Long Terme', + medium: 'Moyen Terme', + short: 'Court Terme', +} + +function toDay(date: string): number { + const origin = new Date(FRISE_START).getTime() + return Math.floor((new Date(date + 'T00:00:00Z').getTime() - origin) / 86_400_000) +} + +function assignSubCols(evs: MarketEvent[]): Map { + const sorted = [...evs].sort((a, b) => a.start_date.localeCompare(b.start_date)) + const colEnds: string[] = [] + const result = new Map() + for (const ev of sorted) { + const end = ev.end_date ?? '2099-12-31' + let placed = false + for (let i = 0; i < colEnds.length; i++) { + if (ev.start_date >= colEnds[i]) { + result.set(ev.id, i); colEnds[i] = end; placed = true; break + } + } + if (!placed) { result.set(ev.id, colEnds.length); colEnds.push(end) } + } + return result +} + +export default function TimelineVertical({ events, refDate, onDateChange }: Props) { + const scrollRef = useRef(null) + const today = useMemo(() => new Date().toISOString().split('T')[0], []) + const todayDay = toDay(today) + const totalDays = todayDay + 60 + const totalHeight = totalDays * SCALE + const refY = toDay(refDate) * SCALE + const todayY = todayDay * SCALE + + const yearMarkers = useMemo(() => { + const origin = new Date(FRISE_START) + const out: { day: number; label: string }[] = [] + for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 1; y++) { + const jan1 = new Date(y, 0, 1) + const day = Math.floor((jan1.getTime() - origin.getTime()) / 86_400_000) + if (day >= 0 && day <= totalDays) out.push({ day, label: String(y) }) + } + return out + }, [totalDays]) + + const byLevel = useMemo(() => { + const g: Record = { long: [], medium: [], short: [] } + for (const ev of events) { + const l = ev.level as Level + if (g[l]) g[l].push(ev) + } + return g + }, [events]) + + const subColsMap = useMemo(() => { + const m = {} as Record> + for (const l of LEVEL_ORDER) m[l] = assignSubCols(byLevel[l]) + return m + }, [byLevel]) + + const maxSubCols = useMemo(() => { + const m = { long: 1, medium: 1, short: 1 } as Record + for (const l of LEVEL_ORDER) { + const vals = [...subColsMap[l].values()] + if (vals.length) m[l] = Math.min(Math.max(...vals) + 1, 4) // cap at 4 sub-cols + } + return m + }, [subColsMap]) + + const colX = useMemo(() => { + const x = {} as Record + let cur = MARGIN_LEFT + for (const l of LEVEL_ORDER) { x[l] = cur; cur += LEVEL_W + COL_GAP } + return x + }, []) + + const totalWidth = MARGIN_LEFT + LEVEL_ORDER.length * (LEVEL_W + COL_GAP) + + function subColW(level: Level): number { + const n = maxSubCols[level] + return Math.max((LEVEL_W - (n - 1) * SUBCOL_GAP) / n, 30) + } + + useEffect(() => { + const el = scrollRef.current + if (!el) return + el.scrollTop = Math.max(0, refY - el.clientHeight / 2) + }, [refDate, refY]) + + return ( +
+ {/* Column headers */} +
+ {LEVEL_ORDER.map((l, i) => ( +
+ {LEVEL_LABELS[l]} +
+ ))} +
+ + {/* Scrollable area */} +
+
+ + {/* Column track backgrounds */} + {LEVEL_ORDER.map(l => ( +
+ ))} + + {/* Year lines + labels */} + {yearMarkers.map(m => ( +
+ + {m.label} + +
+ ))} + + {/* Events */} + {events.map(ev => { + const l = ev.level as Level + const c = COLORS[l] + const sc = subColsMap[l].get(ev.id) ?? 0 + // cap sub-col index to max allowed + const cappedSc = Math.min(sc, maxSubCols[l] - 1) + const scw = subColW(l) + const x = colX[l] + cappedSc * (scw + SUBCOL_GAP) + const topY = toDay(ev.start_date) * SCALE + const endDay = ev.end_date ? toDay(ev.end_date) : totalDays + const h = Math.max((endDay - toDay(ev.start_date)) * SCALE, MIN_EV_H) + const isActive = refDate >= ev.start_date && (!ev.end_date || refDate <= ev.end_date) + const isOngoing = !ev.end_date + const daysSinceStart = Math.max(0, Math.floor( + (new Date(refDate).getTime() - new Date(ev.start_date).getTime()) / 86_400_000 + )) + + return ( +
onDateChange(ev.start_date)} + title={`[${ev.level.toUpperCase()}] ${ev.name}\n${ev.start_date}${ev.end_date ? ' → ' + ev.end_date : ' → en cours'}`} + style={{ + position: 'absolute', + top: topY + 1, + left: x + 1, + width: scw - 2, + height: h - 2, + background: isActive ? c.border : c.bg, + border: `1px solid ${isActive ? c.border : c.border + '88'}`, + borderRadius: 3, + cursor: 'pointer', + overflow: 'hidden', + opacity: isActive ? 1 : 0.7, + zIndex: isActive ? 10 : 2, + backgroundImage: isOngoing && !isActive + ? `linear-gradient(180deg, ${c.bg} 80%, ${c.border}30 100%)` + : undefined, + transition: 'opacity 0.15s', + }} + > +
+ {ev.name} +
+ {h > 46 && isActive && ( +
+ J+{daysSinceStart} +
+ )} + {isOngoing && ( +
+ )} +
+ ) + })} + + {/* Today line */} +
+ aujourd'hui +
+ + {/* Selected date line */} + {refDate !== today && ( +
+ )} + +
+
+
+ ) +} diff --git a/frontend/src/pages/Timeline.tsx b/frontend/src/pages/Timeline.tsx index fb3fe03..e1db52b 100644 --- a/frontend/src/pages/Timeline.tsx +++ b/frontend/src/pages/Timeline.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' -import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List } from 'lucide-react' +import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List, Cpu } from 'lucide-react' import axios from 'axios' import clsx from 'clsx' -import TimelineFrise from '../components/TimelineFrise' +import TimelineVertical from '../components/TimelineVertical' import EventManager from '../components/EventManager' const api = axios.create({ baseURL: '/api' }) @@ -191,6 +191,8 @@ export default function Timeline() { const [generating, setGenerating] = useState(false) const [bootstrapped, setBootstrapped] = useState(false) const [showManager, setShowManager] = useState(false) + const [bootstrappingMA, setBootstrappingMA] = useState(false) + const [maResult, setMaResult] = useState<{ detected: number; saved: number } | null>(null) const fetchDay = useCallback(async (d: string) => { setLoading(true) @@ -250,6 +252,18 @@ export default function Timeline() { } }, [allEvents.length, bootstrapped, bootstrap]) + const bootstrapMA = useCallback(async () => { + setBootstrappingMA(true) + setMaResult(null) + try { + const res = await api.post('/timeline/bootstrap-ma') + setMaResult({ detected: res.data.detected, saved: res.data.saved }) + await fetchEvents() + } catch { /* ignore */ } finally { + setBootstrappingMA(false) + } + }, [fetchEvents]) + const navigate = (days: number) => setRefDate(prev => offsetDate(prev, days)) return ( @@ -278,6 +292,20 @@ export default function Timeline() { Gérer événements + + {maResult && ( + + {maResult.saved} périodes ajoutées + + )}
- {/* Frise chronologique */} + {/* Vertical timeline */} {allEvents.length > 0 && ( - + )} {/* 3 context panels */}