feat: calendar prev week, fullscreen mode, AI weekly highlights

Calendar — "Semaine dernière" tab:
- API now fetches from last Monday (instead of today) and tags events as "prev"
- New tab in CalendarTab with last week's published events (actual values visible)
- fromDate filter bypassed for prev tab; week banner styled in slate

Dashboard — fullscreen:
- Maximize2/Minimize2 button in header using Fullscreen API
- Listens to fullscreenchange to sync icon state

Report — AI "Faits marquants":
- New report_highlights mode in /api/narrative: reads published events from
  prev+current week + drivers, generates 3 structured highlights via Groq
- AiHighlightsButton next to "Faits marquants de la semaine" label
- Parses AI response (-- delimited) into title+body Theme objects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Capucine Gest
2026-06-08 20:39:52 +02:00
co-authored by Claude Sonnet 4.6
parent ce53f97b5b
commit a3d847ce59
5 changed files with 169 additions and 17 deletions
+20 -9
View File
@@ -57,6 +57,8 @@ function nextMonday(): Date {
function getWeekBounds() {
const nm = nextMonday();
const prevStart = new Date(nm); prevStart.setDate(nm.getDate() - 14);
const prevEnd = new Date(nm); prevEnd.setDate(nm.getDate() - 8);
const currentStart = new Date(nm); currentStart.setDate(nm.getDate() - 7);
const currentEnd = new Date(nm); currentEnd.setDate(nm.getDate() - 1);
const nextEnd = new Date(nm); nextEnd.setDate(nm.getDate() + 6);
@@ -64,6 +66,7 @@ function getWeekBounds() {
const next2End = new Date(nm); next2End.setDate(nm.getDate() + 13);
const fmt = (d: Date) => d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
return {
prevWeekLabel: `${fmt(prevStart)} ${fmt(prevEnd)}`,
currentWeekLabel: `${fmt(currentStart)} ${fmt(currentEnd)}`,
nextWeekLabel: `${fmt(nm)} ${fmt(nextEnd)}`,
next2WeekLabel: `${fmt(next2Start)} ${fmt(next2End)}`,
@@ -151,7 +154,7 @@ function EventRow({ ev, isChild, expanded, onToggle }: {
// ── Main ──────────────────────────────────────────────────────────────────────
type WeekTab = "current" | "next" | "next2" | "all";
type WeekTab = "prev" | "current" | "next" | "next2" | "all";
export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
const [filterCcy, setFilterCcy] = useState<Currency | "ALL">("ALL");
@@ -160,7 +163,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
const [weekTab, setWeekTab] = useState<WeekTab>("all");
const [fromDate, setFromDate] = useState<string>(todayIso());
const { currentWeekLabel, nextWeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
const { prevWeekLabel, currentWeekLabel, nextWeekLabel, next2StartLabel, nextMondayIso } = useMemo(getWeekBounds, []);
void nextMondayIso;
const toggle = (groupKey: string) =>
@@ -174,10 +177,12 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
if (filterCcy !== "ALL" && ev.currency !== filterCcy) return false;
if (!showLow && ev.impact === "low") return false;
if (ev.isGroupChild && ev.groupKey && !expanded.has(ev.groupKey)) return false;
if (weekTab === "prev" && ev.week !== "prev") return false;
if (weekTab === "current" && ev.week !== "current") return false;
if (weekTab === "next" && ev.week !== "next") return false;
if (weekTab === "next2" && ev.week !== "next2") return false;
if (isoToLocalDate(ev.date) < fromDate) return false;
// Pour "semaine dernière", ne pas filtrer par fromDate (les events sont passés)
if (weekTab !== "prev" && isoToLocalDate(ev.date) < fromDate) return false;
return true;
}), [events, filterCcy, showLow, expanded, weekTab, fromDate]);
@@ -190,6 +195,7 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
}
days.sort();
const countPrev = events.filter(e => e.week === "prev").length;
const countCurrent = events.filter(e => e.week === "current").length;
const countNext = events.filter(e => e.week === "next").length;
const countNext2 = events.filter(e => e.week === "next2").length;
@@ -219,10 +225,11 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
{/* Onglets semaine */}
<div className="flex gap-0 border-b border-slate-800 bg-slate-900/40">
{([
["all", "Tout", null, null],
["current", "Sem. en cours", currentWeekLabel, countCurrent],
["next", "Sem. prochaine", nextWeekLabel, countNext],
["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2],
["all", "Tout", null, null],
["prev", "Sem. dernière", prevWeekLabel, countPrev],
["current", "Sem. en cours", currentWeekLabel, countCurrent],
["next", "Sem. prochaine", nextWeekLabel, countNext],
["next2", "Sem. +2 et +", `${next2StartLabel} et +`, countNext2],
] as [WeekTab, string, string | null, number | null][]).map(([tab, label, sub, count]) => {
const isActive = weekTab === tab;
const disabled = tab === "next" && !nextWeekAvail && countNext === 0;
@@ -353,14 +360,18 @@ export default function CalendarTab({ events, loading, nextWeekAvail }: Props) {
if (weekTab === "all" && w !== lastWeek) {
lastWeek = w;
const weekBanners: Record<string, string> = {
prev: `Semaine dernière — ${prevWeekLabel}`,
current: `Semaine en cours — ${currentWeekLabel}`,
next: `Semaine prochaine — ${nextWeekLabel}`,
next2: `À partir du ${next2StartLabel} — réunions BC + données`,
};
const isNext2 = w === "next2";
const isPrev = w === "prev";
const bannerCls = isPrev ? "bg-slate-700/30" : isNext2 ? "bg-amber-500/15" : "bg-indigo-500/15";
const textCls = isPrev ? "text-slate-500" : isNext2 ? "text-amber-400" : "text-indigo-400";
rows.push(
<tr key={`wsep_${w}`} className={isNext2 ? "bg-amber-500/15" : "bg-indigo-500/15"}>
<td colSpan={7} className={`px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest ${isNext2 ? "text-amber-400" : "text-indigo-400"}`}>
<tr key={`wsep_${w}`} className={bannerCls}>
<td colSpan={7} className={`px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest ${textCls}`}>
📅 {weekBanners[w] ?? w}
</td>
</tr>
+73 -1
View File
@@ -84,6 +84,69 @@ function Pct({ val }: { val: string }) {
return <span className={`font-mono font-bold text-sm ${c}`}>{n > 0 ? "+" : ""}{n.toFixed(1)}%</span>;
}
// ── Bouton Groq "Faits marquants" ─────────────────────────────────────────────
function AiHighlightsButton({ calEvents, drivers, weekFrom, weekTo, onResult }: {
calEvents: CalendarEvent[];
drivers: DriverData | null;
weekFrom: string;
weekTo: string;
onResult: (themes: Theme[]) => void;
}) {
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
const run = async () => {
setLoading(true); setErr(""); setDone(false);
try {
const prevEvents = calEvents.filter(e => e.week === "prev" || (e.isPublished && e.week === "current"));
const res = await fetch("/api/narrative", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "report_highlights",
data: {
events: prevEvents.map(e => ({ title: e.title, currency: e.currency, actual: e.actual, forecast: e.forecast, previous: e.previous, impact: e.impact })),
drivers: { vix: (drivers as { vix?: number | null } | null)?.vix, brent: (drivers as { brent?: number | null } | null)?.brent, us10y: (drivers as { us10y?: number | null } | null)?.us10y },
weekFrom, weekTo,
},
}),
});
const json = await res.json();
if (json.error) throw new Error(json.error);
const text: string = json.analysis ?? "";
// Parse les 3 blocs séparés par "--"
const blocks = text.split(/\n--\n|^--$/m).map(b => b.trim()).filter(Boolean).slice(0, 3);
const themes: Theme[] = blocks.map(block => {
const lines = block.split("\n").map(l => l.trim()).filter(Boolean);
return { title: lines[0] ?? "", body: lines.slice(1).join(" ") };
});
onResult(themes);
setDone(true);
setTimeout(() => setDone(false), 3000);
} catch (e) {
setErr(String(e).replace(/^Error:\s*/i, "").slice(0, 60));
} finally { setLoading(false); }
};
return (
<div className="flex items-center gap-2">
<button onClick={run} disabled={loading}
className={`no-print flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[10px] font-semibold transition-all ${
done ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" :
loading ? "bg-sky-500/10 text-sky-400 border border-sky-500/20 cursor-wait" :
"bg-sky-500/15 text-sky-400 border border-sky-500/25 hover:bg-sky-500/25"
}`}>
{loading ? <Loader2 size={10} className="animate-spin" />
: done ? <Check size={10} />
: <Sparkles size={10} />}
{loading ? "Génération…" : done ? "Injectés !" : "Générer avec IA"}
</button>
{err && <span className="text-[9px] text-red-400 truncate max-w-[140px]" title={err}> {err}</span>}
</div>
);
}
// ── Bouton Groq par devise ────────────────────────────────────────────────────
function AiButton({ ccy, weekFrom, weekTo, pct, cotHistory, onResult }: {
ccy: string; weekFrom: string; weekTo: string; pct: string;
@@ -309,7 +372,16 @@ export default function ReportTab({ calEvents, drivers, cotHistory }: Props) {
{/* Thèmes clés */}
<div className="space-y-2 mt-4">
<p className="text-slate-600 text-[9px] uppercase tracking-widest font-semibold no-print">Faits marquants de la semaine</p>
<div className="flex items-center justify-between no-print">
<p className="text-slate-600 text-[9px] uppercase tracking-widest font-semibold">Faits marquants de la semaine</p>
<AiHighlightsButton
calEvents={calEvents}
drivers={drivers}
weekFrom={state.weekFrom}
weekTo={state.weekTo}
onResult={themes => upd({ themes })}
/>
</div>
{state.themes.map((theme, i) => (
<div key={i} className="group relative flex gap-3 p-3 rounded-lg bg-white/[0.03] border-l-2 border-sky-500/60">
<div className="flex-1 space-y-0.5">