From 7be296231caec0669b7851bff773128ca1d56545 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Tue, 3 Mar 2026 11:45:19 +0100 Subject: [PATCH] feat: add weather trend chart showing indicator averages over time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/weather/[id]/page.tsx | 13 +- src/components/layout/Header.tsx | 2 +- src/components/layout/ThemeToggle.tsx | 1 + src/components/weather/WeatherTrendChart.tsx | 307 +++++++++++++++++++ src/components/weather/index.ts | 1 + src/services/weather.ts | 66 ++++ 6 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 src/components/weather/WeatherTrendChart.tsx diff --git a/src/app/weather/[id]/page.tsx b/src/app/weather/[id]/page.tsx index 3cf1b28..bab39a9 100644 --- a/src/app/weather/[id]/page.tsx +++ b/src/app/weather/[id]/page.tsx @@ -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 */} + {/* Évolution dans le temps */} + + {/* Live Wrapper + Board */} ) : ( { + // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); diff --git a/src/components/weather/WeatherTrendChart.tsx b/src/components/weather/WeatherTrendChart.tsx new file mode 100644 index 0000000..9f6b296 --- /dev/null +++ b/src/components/weather/WeatherTrendChart.tsx @@ -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 +): 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(null); + + if (data.length < 2) return null; + + const hoveredPt = hoveredIdx !== null ? data[hoveredIdx] : null; + + return ( +
+ + + {isOpen && ( +
+ {/* Legend */} +
+ {INDICATORS.map((ind) => ( +
+ + + {ind.icon} {ind.label} + +
+ ))} +
+ + {/* Tooltip */} +
+ {hoveredPt ? ( +
+ + {hoveredPt.title} —{' '} + {new Date(hoveredPt.date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} + {hoveredPt.sessionId === currentSessionId && ( + + actuelle + + )} + + {INDICATORS.map((ind) => { + const score = hoveredPt[ind.key]; + return ( + + {ind.icon}{' '} + {score !== null ? ( + {score.toFixed(1)} + ) : ( + + )} + + ); + })} +
+ ) : ( +

Survolez le graphe pour voir les détails

+ )} +
+ + {/* SVG Chart */} +
+ + + {/* Y-axis grid lines + labels */} + {Y_TICKS.map((tick) => { + const y = scoreToY(tick); + return ( + + + + {tick} + + + ); + })} + + {/* "Mieux" / "Moins bien" Y-axis labels */} + + mieux ↑ + + + {/* X-axis labels */} + {data.map((pt, i) => { + const x = indexToX(i, data.length); + const isCurrentSession = pt.sessionId === currentSessionId; + return ( + + {new Date(pt.date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + + ); + })} + + {/* 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 ( + + ); + })()} + + {/* Lines */} + {INDICATORS.map((ind) => { + const d = buildPath(data, ind.key); + if (!d) return null; + return ( + + ); + })} + + {/* 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 ( + + ); + }) + )} + + {/* 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 ( + setHoveredIdx(i)} + onMouseLeave={() => setHoveredIdx(null)} + style={{ cursor: 'crosshair' }} + /> + ); + })} + + +
+ +

+ Score 1 = ☀️ (meilleur) · Score 19 = dégradé +

+
+ )} +
+ ); +} diff --git a/src/components/weather/index.ts b/src/components/weather/index.ts index 7182374..84c27ff 100644 --- a/src/components/weather/index.ts +++ b/src/components/weather/index.ts @@ -3,3 +3,4 @@ export { WeatherCard } from './WeatherCard'; export { WeatherLiveWrapper } from './WeatherLiveWrapper'; export { WeatherInfoPanel } from './WeatherInfoPanel'; export { WeatherAverageBar } from './WeatherAverageBar'; +export { WeatherTrendChart } from './WeatherTrendChart'; diff --git a/src/services/weather.ts b/src/services/weather.ts index c22df0b..a11c54d 100644 --- a/src/services/weather.ts +++ b/src/services/weather.ts @@ -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 { + 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(); + 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)), + })); +}