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
+15 -4
View File
@@ -32,7 +32,7 @@ export interface CalendarEvent {
forecast: string | null;
previous: string | null;
isPublished: boolean;
week: "current" | "next" | "next2"; // semaine de l'événement
week: "prev" | "current" | "next" | "next2"; // semaine de l'événement
source: "ff" | "fred"; // source de la donnée
groupKey: string | null;
isGroupParent: boolean;
@@ -322,7 +322,17 @@ export async function GET() {
const fredKey = process.env.FRED_API_KEY;
const { nextMonday, next2Monday } = getWeekBounds();
const fromDate = new Date().toISOString().slice(0, 10);
// Fetch depuis le lundi de la semaine précédente pour inclure "Semaine dernière"
const now = new Date();
const dayOfWeek = now.getDay(); // 0=dim, 1=lun…6=sam
const daysToThisMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const thisMonday = new Date(now);
thisMonday.setDate(now.getDate() - daysToThisMonday);
thisMonday.setHours(0, 0, 0, 0);
const lastMonday = new Date(thisMonday);
lastMonday.setDate(thisMonday.getDate() - 7);
const fromDate = lastMonday.toISOString().slice(0, 10);
const toDateObj = new Date();
toDateObj.setDate(toDateObj.getDate() + 14);
const toDate = toDateObj.toISOString().slice(0, 10);
@@ -347,9 +357,10 @@ export async function GET() {
const events: CalendarEvent[] = [];
const weekOf = (date: Date): "current" | "next" | "next2" =>
const weekOf = (date: Date): "prev" | "current" | "next" | "next2" =>
date >= next2Monday ? "next2" :
date >= nextMonday ? "next" : "current";
date >= nextMonday ? "next" :
date >= thisMonday ? "current" : "prev";
if (useScraping) {
// ── BASE : TE HTML ────────────────────────────────────────────────────────
+37 -2
View File
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
}
let body: {
mode: "cb_analysis" | "expert_opinion" | "summary" | "divergence" | "report_ccy";
mode: "cb_analysis" | "expert_opinion" | "summary" | "divergence" | "report_ccy" | "report_highlights";
currency?: string;
data?: unknown;
userInput?: string;
@@ -94,6 +94,41 @@ Rédige un paragraphe fluide de 80 à 120 mots, style analyste macro professionn
break;
}
case "report_highlights": {
const d = data as {
events?: Array<{ title: string; currency: string; actual?: string | null; forecast?: string | null; previous?: string | null; impact: string }>;
drivers?: { vix?: number | null; brent?: number | null; us10y?: number | null };
weekFrom?: string; weekTo?: string;
};
const published = (d.events ?? [])
.filter(e => e.actual && e.impact !== "low")
.slice(0, 12)
.map(e => `${e.currency} · ${e.title} : actuel=${e.actual ?? "—"} / prévu=${e.forecast ?? "—"} / précédent=${e.previous ?? "—"}`)
.join("\n");
const weekRange = d.weekFrom && d.weekTo ? `${d.weekFrom} au ${d.weekTo}` : "de la semaine";
userMessage = `Génère 3 faits marquants de la semaine du ${weekRange} pour un trader Forex G10.
Données publiées cette semaine :
${published || "Aucune publication disponible"}
Contexte marché : VIX=${d.drivers?.vix?.toFixed(1) ?? "N/D"}, Brent=$${d.drivers?.brent?.toFixed(1) ?? "N/D"}, US 10Y=${d.drivers?.us10y?.toFixed(2) ?? "N/D"}%
Pour chaque fait marquant, fournis :
- TITRE (6 mots max, en majuscules)
- DESCRIPTION (30-40 mots) : chiffre clé, surprise vs consensus, impact sur les devises concernées
Formate ta réponse EXACTEMENT ainsi (3 blocs, séparés par --) :
TITRE1
Description1
--
TITRE2
Description2
--
TITRE3
Description3`;
break;
}
case "summary":
default:
userMessage = `Génère une synthèse macro hebdomadaire pour ${currency} basée sur ces données :
@@ -109,7 +144,7 @@ Résumé en 3 points : situation actuelle, signal directionnel, risque principal
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
max_tokens: 300,
max_tokens: mode === "report_highlights" ? 500 : 300,
temperature: 0.3,
});
+24 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { RefreshCw, Zap, Database, Activity } from "lucide-react";
import { RefreshCw, Zap, Database, Activity, Maximize2, Minimize2 } from "lucide-react";
import { CURRENCIES, CURRENCY_META } from "@/lib/constants";
import type { Currency, DriverData, SentimentEntry, CotEntry } from "@/lib/types";
import type { RateProbData } from "@/lib/rateprobability";
@@ -40,6 +40,7 @@ export default function Dashboard() {
const [activeDivergences, setActiveDivergences] = useState<{ currency: Currency; score: number }[]>([]);
const [driversFromCache, setDriversFromCache] = useState(false);
const [driversCacheAge, setDriversCacheAge] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// ── Sentiment multi-paires Myfxbook → {CCY: {longPct, shortPct, pair}} ──────
// Pour chaque devise, on calcule le % "long CCY" en moyenne pondérée (par volume)
@@ -255,6 +256,20 @@ export default function Dashboard() {
if (activeTab === "cot" && !cotHistory) refreshCotHistory();
}, [activeTab, cotHistory, refreshCotHistory]);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {});
} else {
document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {});
}
}, []);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
const handleDivergenceUpdate = useCallback((currency: Currency, score: number) => {
setActiveDivergences((prev) => {
const filtered = prev.filter((d) => d.currency !== currency);
@@ -309,6 +324,14 @@ export default function Dashboard() {
<RefreshCw size={13} className={loading ? "animate-spin" : ""} />
{loading ? "Chargement…" : "Rafraîchir"}
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? "Quitter le plein écran" : "Plein écran"}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors border border-slate-800 hover:border-slate-600 rounded-md px-2 py-1"
>
{isFullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
</button>
</div>
</header>