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:
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)),
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user