feat: add weather trend chart showing indicator averages over time
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:
2026-03-03 11:45:19 +01:00
parent c3b653601c
commit 7be296231c
6 changed files with 387 additions and 3 deletions

View File

@@ -2,13 +2,18 @@ import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getWeatherSessionById, getPreviousWeatherEntriesForUsers } from '@/services/weather';
import {
getWeatherSessionById,
getPreviousWeatherEntriesForUsers,
getWeatherSessionsHistory,
} from '@/services/weather';
import { getUserTeams } from '@/services/teams';
import {
WeatherBoard,
WeatherLiveWrapper,
WeatherInfoPanel,
WeatherAverageBar,
WeatherTrendChart,
} from '@/components/weather';
import { Badge } from '@/components/ui';
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
@@ -33,9 +38,10 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
const allUserIds = [session.user.id, ...session.shares.map((s: { userId: string }) => s.userId)];
const [previousEntries, userTeams] = await Promise.all([
const [previousEntries, userTeams, history] = await Promise.all([
getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds),
getUserTeams(authSession.user.id),
getWeatherSessionsHistory(authSession.user.id),
]);
return (
@@ -79,6 +85,9 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
{/* Info sur les catégories */}
<WeatherInfoPanel />
{/* Évolution dans le temps */}
<WeatherTrendChart data={history} currentSessionId={session.id} />
{/* Live Wrapper + Board */}
<WeatherLiveWrapper
sessionId={session.id}

View File

@@ -31,7 +31,7 @@ export async function Header() {
{isAuthenticated ? (
<UserMenu
userName={session.user?.name}
userEmail={session.user?.email!}
userEmail={session.user?.email ?? ''}
/>
) : (
<Link

View File

@@ -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);
}, []);

View 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>
);
}

View File

@@ -3,3 +3,4 @@ export { WeatherCard } from './WeatherCard';
export { WeatherLiveWrapper } from './WeatherLiveWrapper';
export { WeatherInfoPanel } from './WeatherInfoPanel';
export { WeatherAverageBar } from './WeatherAverageBar';
export { WeatherTrendChart } from './WeatherTrendChart';

View File

@@ -8,8 +8,19 @@ import {
getSessionByIdGeneric,
} from '@/services/session-queries';
import { getWeekBounds } from '@/lib/date-utils';
import { getEmojiScore } from '@/lib/weather-utils';
import type { ShareRole } from '@prisma/client';
export type WeatherHistoryPoint = {
sessionId: string;
title: string;
date: Date;
performance: number | null;
moral: number | null;
flux: number | null;
valueCreation: number | null;
};
const weatherInclude = {
user: { select: { id: true, name: true, email: true } },
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
@@ -343,3 +354,58 @@ export type WeatherSessionEventType =
export const createWeatherSessionEvent = weatherShareEvents.createEvent;
export const getWeatherSessionEvents = weatherShareEvents.getEvents;
export const getLatestWeatherEventTimestamp = weatherShareEvents.getLatestEventTimestamp;
// ============================================
// Weather History (for trend chart)
// ============================================
function avgScore(emojis: (string | null)[]): number | null {
const scores = emojis.map(getEmojiScore).filter((s): s is number => s !== null);
if (scores.length === 0) return null;
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
}
export async function getWeatherSessionsHistory(userId: string): Promise<WeatherHistoryPoint[]> {
const entrySelect = {
performanceEmoji: true,
moralEmoji: true,
fluxEmoji: true,
valueCreationEmoji: true,
} as const;
const [ownSessions, sharedRaw] = await Promise.all([
prisma.weatherSession.findMany({
where: { userId },
select: { id: true, title: true, date: true, entries: { select: entrySelect } },
}),
prisma.weatherSessionShare.findMany({
where: { userId },
select: {
session: {
select: { id: true, title: true, date: true, entries: { select: entrySelect } },
},
},
}),
]);
const seen = new Set<string>();
const all: { id: string; title: string; date: Date; entries: typeof ownSessions[0]['entries'] }[] = [];
for (const s of [...ownSessions, ...sharedRaw.map((r) => r.session)]) {
if (!seen.has(s.id)) {
seen.add(s.id);
all.push(s);
}
}
all.sort((a, b) => a.date.getTime() - b.date.getTime());
return all.map((s) => ({
sessionId: s.id,
title: s.title,
date: s.date,
performance: avgScore(s.entries.map((e) => e.performanceEmoji)),
moral: avgScore(s.entries.map((e) => e.moralEmoji)),
flux: avgScore(s.entries.map((e) => e.fluxEmoji)),
valueCreation: avgScore(s.entries.map((e) => e.valueCreationEmoji)),
}));
}