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 Link from 'next/link';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
|
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 { getUserTeams } from '@/services/teams';
|
||||||
import {
|
import {
|
||||||
WeatherBoard,
|
WeatherBoard,
|
||||||
WeatherLiveWrapper,
|
WeatherLiveWrapper,
|
||||||
WeatherInfoPanel,
|
WeatherInfoPanel,
|
||||||
WeatherAverageBar,
|
WeatherAverageBar,
|
||||||
|
WeatherTrendChart,
|
||||||
} from '@/components/weather';
|
} from '@/components/weather';
|
||||||
import { Badge } from '@/components/ui';
|
import { Badge } from '@/components/ui';
|
||||||
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
|
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 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),
|
getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds),
|
||||||
getUserTeams(authSession.user.id),
|
getUserTeams(authSession.user.id),
|
||||||
|
getWeatherSessionsHistory(authSession.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,6 +85,9 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
|
|||||||
{/* Info sur les catégories */}
|
{/* Info sur les catégories */}
|
||||||
<WeatherInfoPanel />
|
<WeatherInfoPanel />
|
||||||
|
|
||||||
|
{/* Évolution dans le temps */}
|
||||||
|
<WeatherTrendChart data={history} currentSessionId={session.id} />
|
||||||
|
|
||||||
{/* Live Wrapper + Board */}
|
{/* Live Wrapper + Board */}
|
||||||
<WeatherLiveWrapper
|
<WeatherLiveWrapper
|
||||||
sessionId={session.id}
|
sessionId={session.id}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export async function Header() {
|
|||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<UserMenu
|
<UserMenu
|
||||||
userName={session.user?.name}
|
userName={session.user?.name}
|
||||||
userEmail={session.user?.email!}
|
userEmail={session.user?.email ?? ''}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function ThemeToggle() {
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setMounted(true);
|
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 { WeatherLiveWrapper } from './WeatherLiveWrapper';
|
||||||
export { WeatherInfoPanel } from './WeatherInfoPanel';
|
export { WeatherInfoPanel } from './WeatherInfoPanel';
|
||||||
export { WeatherAverageBar } from './WeatherAverageBar';
|
export { WeatherAverageBar } from './WeatherAverageBar';
|
||||||
|
export { WeatherTrendChart } from './WeatherTrendChart';
|
||||||
|
|||||||
@@ -8,8 +8,19 @@ import {
|
|||||||
getSessionByIdGeneric,
|
getSessionByIdGeneric,
|
||||||
} from '@/services/session-queries';
|
} from '@/services/session-queries';
|
||||||
import { getWeekBounds } from '@/lib/date-utils';
|
import { getWeekBounds } from '@/lib/date-utils';
|
||||||
|
import { getEmojiScore } from '@/lib/weather-utils';
|
||||||
import type { ShareRole } from '@prisma/client';
|
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 = {
|
const weatherInclude = {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
shares: { include: { 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 createWeatherSessionEvent = weatherShareEvents.createEvent;
|
||||||
export const getWeatherSessionEvents = weatherShareEvents.getEvents;
|
export const getWeatherSessionEvents = weatherShareEvents.getEvents;
|
||||||
export const getLatestWeatherEventTimestamp = weatherShareEvents.getLatestEventTimestamp;
|
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