feat: add weather trend chart showing indicator averages over time
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
Adds a collapsible SVG line graph on weather session pages displaying the evolution of all 4 indicators (Performance, Moral, Flux, Création de valeur) across sessions, with per-session average scores, hover tooltips, and a marker on the current session. Also fixes pre-existing lint errors: non-null assertion on optional chain in Header and eslint-disable for intentional hydration pattern in ThemeToggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ export async function Header() {
|
||||
{isAuthenticated ? (
|
||||
<UserMenu
|
||||
userName={session.user?.name}
|
||||
userEmail={session.user?.email!}
|
||||
userEmail={session.user?.email ?? ''}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
|
||||
@@ -8,6 +8,7 @@ export function ThemeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
|
||||
307
src/components/weather/WeatherTrendChart.tsx
Normal file
307
src/components/weather/WeatherTrendChart.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { WeatherHistoryPoint } from '@/services/weather';
|
||||
|
||||
interface WeatherTrendChartProps {
|
||||
data: WeatherHistoryPoint[];
|
||||
currentSessionId?: string;
|
||||
}
|
||||
|
||||
const INDICATORS = [
|
||||
{ key: 'performance' as const, label: 'Performance', icon: '☀️', color: '#f59e0b' },
|
||||
{ key: 'moral' as const, label: 'Moral', icon: '😊', color: '#10b981' },
|
||||
{ key: 'flux' as const, label: 'Flux', icon: '🌊', color: '#3b82f6' },
|
||||
{ key: 'valueCreation' as const, label: 'Création de valeur', icon: '💎', color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
// SVG dimensions
|
||||
const W = 760;
|
||||
const H = 200;
|
||||
const ML = 44; // left margin (y-axis labels)
|
||||
const MR = 24;
|
||||
const MT = 12;
|
||||
const MB = 48; // bottom margin (x-axis labels)
|
||||
const IW = W - ML - MR;
|
||||
const IH = H - MT - MB;
|
||||
|
||||
// Score 1 = ☀️ = best → top of chart (y=0 inner)
|
||||
// Score 19 = worst → bottom (y=IH inner)
|
||||
function scoreToY(score: number): number {
|
||||
return ((score - 1) / 18) * IH;
|
||||
}
|
||||
|
||||
function indexToX(i: number, total: number): number {
|
||||
if (total <= 1) return IW / 2;
|
||||
return (i / (total - 1)) * IW;
|
||||
}
|
||||
|
||||
function buildPath(
|
||||
data: WeatherHistoryPoint[],
|
||||
key: keyof Pick<WeatherHistoryPoint, 'performance' | 'moral' | 'flux' | 'valueCreation'>
|
||||
): string {
|
||||
let path = '';
|
||||
let movePending = true;
|
||||
data.forEach((pt, i) => {
|
||||
const score = pt[key];
|
||||
if (score === null) {
|
||||
movePending = true;
|
||||
return;
|
||||
}
|
||||
const x = indexToX(i, data.length);
|
||||
const y = scoreToY(score);
|
||||
if (movePending) {
|
||||
path += `M ${x} ${y}`;
|
||||
movePending = false;
|
||||
} else {
|
||||
path += ` L ${x} ${y}`;
|
||||
}
|
||||
});
|
||||
return path;
|
||||
}
|
||||
|
||||
const Y_TICKS = [1, 5, 10, 14, 19];
|
||||
|
||||
export function WeatherTrendChart({ data, currentSessionId }: WeatherTrendChartProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const hoveredPt = hoveredIdx !== null ? data[hoveredIdx] : null;
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-border bg-card-hover">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Évolution dans le temps
|
||||
<span className="ml-2 text-xs font-normal text-muted">{data.length} sessions</span>
|
||||
</h3>
|
||||
<svg
|
||||
className={`h-4 w-4 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border px-4 py-4">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 mb-3">
|
||||
{INDICATORS.map((ind) => (
|
||||
<div key={ind.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-5 h-0.5 rounded-full"
|
||||
style={{ backgroundColor: ind.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
{ind.icon} {ind.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="min-h-[28px] mb-1">
|
||||
{hoveredPt ? (
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{hoveredPt.title} —{' '}
|
||||
{new Date(hoveredPt.date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
{hoveredPt.sessionId === currentSessionId && (
|
||||
<span className="ml-1.5 text-[10px] text-muted bg-card-hover px-1.5 py-0.5 rounded-full border border-border">
|
||||
actuelle
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{INDICATORS.map((ind) => {
|
||||
const score = hoveredPt[ind.key];
|
||||
return (
|
||||
<span key={ind.key} className="flex items-center gap-1" style={{ color: ind.color }}>
|
||||
{ind.icon}{' '}
|
||||
{score !== null ? (
|
||||
<span className="font-medium">{score.toFixed(1)}</span>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted">Survolez le graphe pour voir les détails</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SVG Chart */}
|
||||
<div className="w-full overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full"
|
||||
style={{ minWidth: `${Math.max(data.length * 40, 300)}px` }}
|
||||
>
|
||||
<g transform={`translate(${ML}, ${MT})`}>
|
||||
{/* Y-axis grid lines + labels */}
|
||||
{Y_TICKS.map((tick) => {
|
||||
const y = scoreToY(tick);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={IW}
|
||||
y2={y}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={tick === 1 || tick === 19 ? '0' : '4 3'}
|
||||
/>
|
||||
<text
|
||||
x={-6}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* "Mieux" / "Moins bien" Y-axis labels */}
|
||||
<text
|
||||
x={-6}
|
||||
y={-2}
|
||||
textAnchor="end"
|
||||
fontSize={9}
|
||||
fill="var(--muted-foreground)"
|
||||
opacity={0.7}
|
||||
>
|
||||
mieux ↑
|
||||
</text>
|
||||
|
||||
{/* X-axis labels */}
|
||||
{data.map((pt, i) => {
|
||||
const x = indexToX(i, data.length);
|
||||
const isCurrentSession = pt.sessionId === currentSessionId;
|
||||
return (
|
||||
<text
|
||||
key={pt.sessionId}
|
||||
x={x}
|
||||
y={IH + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fill={isCurrentSession ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
fontWeight={isCurrentSession ? 600 : 400}
|
||||
>
|
||||
{new Date(pt.date).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Current session vertical marker */}
|
||||
{currentSessionId && (() => {
|
||||
const idx = data.findIndex((pt) => pt.sessionId === currentSessionId);
|
||||
if (idx === -1) return null;
|
||||
const x = indexToX(idx, data.length);
|
||||
return (
|
||||
<line
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={IH}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
opacity={0.5}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Lines */}
|
||||
{INDICATORS.map((ind) => {
|
||||
const d = buildPath(data, ind.key);
|
||||
if (!d) return null;
|
||||
return (
|
||||
<path
|
||||
key={ind.key}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={ind.color}
|
||||
strokeWidth={hoveredIdx !== null ? 1.5 : 2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={0.85}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Dots */}
|
||||
{INDICATORS.map((ind) =>
|
||||
data.map((pt, i) => {
|
||||
const score = pt[ind.key];
|
||||
if (score === null) return null;
|
||||
const x = indexToX(i, data.length);
|
||||
const y = scoreToY(score);
|
||||
const isHovered = hoveredIdx === i;
|
||||
const isCurrent = pt.sessionId === currentSessionId;
|
||||
return (
|
||||
<circle
|
||||
key={`${ind.key}-${i}`}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isHovered ? 5 : isCurrent ? 4 : 3}
|
||||
fill={ind.color}
|
||||
stroke="var(--card)"
|
||||
strokeWidth={isHovered ? 2 : 1.5}
|
||||
opacity={isHovered ? 1 : 0.9}
|
||||
style={{ transition: 'r 0.1s, opacity 0.1s' }}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Invisible hover zones (vertical strips) */}
|
||||
{data.map((pt, i) => {
|
||||
const x = indexToX(i, data.length);
|
||||
const zoneW = data.length > 1 ? IW / (data.length - 1) : IW;
|
||||
return (
|
||||
<rect
|
||||
key={pt.sessionId}
|
||||
x={x - zoneW / 2}
|
||||
y={0}
|
||||
width={zoneW}
|
||||
height={IH}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHoveredIdx(i)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
style={{ cursor: 'crosshair' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted mt-1 text-right">
|
||||
Score 1 = ☀️ (meilleur) · Score 19 = dégradé
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export { WeatherCard } from './WeatherCard';
|
||||
export { WeatherLiveWrapper } from './WeatherLiveWrapper';
|
||||
export { WeatherInfoPanel } from './WeatherInfoPanel';
|
||||
export { WeatherAverageBar } from './WeatherAverageBar';
|
||||
export { WeatherTrendChart } from './WeatherTrendChart';
|
||||
|
||||
Reference in New Issue
Block a user