Compare commits

..

10 Commits

Author SHA1 Message Date
74b1b2e838 fix: restore WeatherAverageBar component in session header and adjust styling
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m12s
Reintroduced the WeatherAverageBar component in the WeatherSessionPage to display team averages. Updated the styling of the WeatherAverageBar for improved spacing. Enhanced the EvolutionIndicator component to use dynamic background colors for better visibility of status indicators.
2026-02-25 07:55:01 +01:00
73219c89fb fix: make evolution indicators visually prominent with badge style
Replace plain text-xs arrows with 20×20px colored circular badges
(green ↑, red ↓, muted →) to ensure they are clearly visible next
to emoji cells. Also widen emoji columns from w-24 → w-28 to give
the badge room without overflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 18:18:28 +01:00
30c2b6cc1e fix: display evolution indicators inline (flex-row) next to emoji instead of below 2026-02-24 17:17:45 +01:00
51bc187374 fix: convert Map to Record for server-client boundary, remove dead currentUser prop
- WeatherBoard: change previousEntries type from Map<string, PreviousEntry> to Record<string, PreviousEntry> and update lookup from .get() to bracket notation
- page.tsx: wrap previousEntries with Object.fromEntries() before passing as prop, remove unused currentUser prop
- WeatherCard: remove spurious eslint-disable-next-line comment for non-existent rule react-hooks/set-state-in-effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:12:46 +01:00
3e869bf8ad feat: show evolution indicators per person per axis in weather board
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:02:31 +01:00
3b212d6dda feat: fetch and pass previous weather entries through component tree
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:00:12 +01:00
9b8c9efbd6 feat: display team weather average bar in session header 2026-02-24 16:57:26 +01:00
11c770da9c feat: add WeatherAverageBar component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:55:57 +01:00
220dcf87b9 feat: add getPreviousWeatherEntriesForUsers service function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:54:21 +01:00
6b8d3c42f7 refactor: extract WEATHER_EMOJIS and add scoring utils to weather-utils.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:50:26 +01:00
7 changed files with 331 additions and 81 deletions

View File

@@ -2,9 +2,9 @@ import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops';
import { getWeatherSessionById } from '@/services/weather';
import { getWeatherSessionById, getPreviousWeatherEntriesForUsers } from '@/services/weather';
import { getUserTeams } from '@/services/teams';
import { WeatherBoard, WeatherLiveWrapper, WeatherInfoPanel } from '@/components/weather';
import { WeatherBoard, WeatherLiveWrapper, WeatherInfoPanel, WeatherAverageBar } from '@/components/weather';
import { Badge } from '@/components/ui';
import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle';
@@ -20,15 +20,22 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
return null;
}
const [session, userTeams] = await Promise.all([
getWeatherSessionById(id, authSession.user.id),
getUserTeams(authSession.user.id),
]);
const session = await getWeatherSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
const allUserIds = [
session.user.id,
...session.shares.map((s: { userId: string }) => s.userId),
];
const [previousEntries, userTeams] = await Promise.all([
getPreviousWeatherEntriesForUsers(session.id, session.date, allUserIds),
getUserTeams(authSession.user.id),
]);
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
@@ -80,14 +87,10 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
canEdit={session.canEdit}
userTeams={userTeams}
>
<WeatherAverageBar entries={session.entries} />
<WeatherBoard
sessionId={session.id}
currentUserId={authSession.user.id}
currentUser={{
id: authSession.user.id,
name: authSession.user.name ?? null,
email: authSession.user.email ?? '',
}}
entries={session.entries}
shares={session.shares}
owner={{
@@ -96,6 +99,7 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
email: session.user.email ?? '',
}}
canEdit={session.canEdit}
previousEntries={Object.fromEntries(previousEntries)}
/>
</WeatherLiveWrapper>
</main>

View File

@@ -0,0 +1,44 @@
// src/components/weather/WeatherAverageBar.tsx
import { getAverageEmoji } from '@/lib/weather-utils';
interface WeatherEntry {
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
}
interface WeatherAverageBarProps {
entries: WeatherEntry[];
}
const AXES = [
{ key: 'performanceEmoji' as const, label: 'Performance' },
{ key: 'moralEmoji' as const, label: 'Moral' },
{ key: 'fluxEmoji' as const, label: 'Flux' },
{ key: 'valueCreationEmoji' as const, label: 'Création de valeur' },
];
export function WeatherAverageBar({ entries }: WeatherAverageBarProps) {
if (entries.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-3 mb-4">
<span className="text-xs font-medium text-muted uppercase tracking-wide">
Moyenne équipe
</span>
{AXES.map(({ key, label }) => {
const avg = getAverageEmoji(entries.map((e) => e[key]));
return (
<div
key={key}
className="flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1"
>
<span className="text-lg leading-none">{avg ?? '—'}</span>
<span className="text-xs text-muted">{label}</span>
</div>
);
})}
</div>
);
}

View File

@@ -28,14 +28,16 @@ interface Share {
};
}
type PreviousEntry = {
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
};
interface WeatherBoardProps {
sessionId: string;
currentUserId: string;
currentUser: {
id: string;
name: string | null;
email: string;
};
entries: WeatherEntry[];
shares: Share[];
owner: {
@@ -44,6 +46,7 @@ interface WeatherBoardProps {
email: string;
};
canEdit: boolean;
previousEntries: Record<string, PreviousEntry>;
}
export function WeatherBoard({
@@ -53,6 +56,7 @@ export function WeatherBoard({
shares,
owner,
canEdit,
previousEntries,
}: WeatherBoardProps) {
// Get all users who have access: owner + shared users
const allUsers = useMemo(() => {
@@ -137,6 +141,7 @@ export function WeatherBoard({
currentUserId={currentUserId}
entry={entry}
canEdit={canEdit}
previousEntry={previousEntries[entry.userId] ?? null}
/>
))}
</tbody>

View File

@@ -5,29 +5,7 @@ import { createOrUpdateWeatherEntry } from '@/actions/weather';
import { Avatar } from '@/components/ui/Avatar';
import { Textarea } from '@/components/ui/Textarea';
import { Select } from '@/components/ui/Select';
const WEATHER_EMOJIS = [
{ emoji: '', label: 'Aucun' },
{ emoji: '☀️', label: 'Soleil' },
{ emoji: '🌤️', label: 'Soleil derrière nuage' },
{ emoji: '⛅', label: 'Soleil et nuages' },
{ emoji: '☁️', label: 'Nuages' },
{ emoji: '🌦️', label: 'Soleil et pluie' },
{ emoji: '🌧️', label: 'Pluie' },
{ emoji: '⛈️', label: 'Orage et pluie' },
{ emoji: '🌩️', label: 'Éclair' },
{ emoji: '❄️', label: 'Neige' },
{ emoji: '🌨️', label: 'Neige qui tombe' },
{ emoji: '🌪️', label: 'Tornade' },
{ emoji: '🌫️', label: 'Brouillard' },
{ emoji: '🌈', label: 'Arc-en-ciel' },
{ emoji: '🌊', label: 'Vague' },
{ emoji: '🔥', label: 'Feu' },
{ emoji: '💨', label: 'Vent' },
{ emoji: '⭐', label: 'Étoile' },
{ emoji: '🌟', label: 'Étoile brillante' },
{ emoji: '✨', label: 'Étincelles' },
];
import { WEATHER_EMOJIS, getEmojiEvolution } from '@/lib/weather-utils';
interface WeatherEntry {
id: string;
@@ -44,14 +22,74 @@ interface WeatherEntry {
};
}
type PreviousEntry = {
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
};
interface WeatherCardProps {
sessionId: string;
currentUserId: string;
entry: WeatherEntry;
canEdit: boolean;
previousEntry?: PreviousEntry | null;
}
export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: WeatherCardProps) {
function EvolutionIndicator({
current,
previous,
}: {
current: string | null;
previous: string | null | undefined;
}) {
const direction = getEmojiEvolution(current, previous);
if (direction === null) return null;
if (direction === 'up') {
return (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold shrink-0"
style={{
backgroundColor: 'color-mix(in srgb, var(--success) 18%, transparent)',
color: 'var(--success)',
}}
title="Amélioration"
>
</span>
);
}
if (direction === 'down') {
return (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold shrink-0"
style={{
backgroundColor: 'color-mix(in srgb, var(--destructive) 18%, transparent)',
color: 'var(--destructive)',
}}
title="Dégradation"
>
</span>
);
}
return (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold shrink-0"
style={{
backgroundColor: 'color-mix(in srgb, var(--muted) 15%, transparent)',
color: 'var(--muted)',
}}
title="Stable"
>
</span>
);
}
export function WeatherCard({ sessionId, currentUserId, entry, canEdit, previousEntry }: WeatherCardProps) {
const [isPending, startTransition] = useTransition();
const [notes, setNotes] = useState(entry.notes || '');
const [performanceEmoji, setPerformanceEmoji] = useState(entry.performanceEmoji || null);
@@ -64,7 +102,6 @@ export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: Weathe
// Sync local state with props when they change (e.g., from SSE refresh)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setNotes(entry.notes || '');
setPerformanceEmoji(entry.performanceEmoji || null);
setMoralEmoji(entry.moralEmoji || null);
@@ -139,66 +176,90 @@ export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: Weathe
</td>
{/* Performance */}
<td className="w-24 px-2 py-3">
<td className="w-28 px-2 py-3">
{canEditThis ? (
<div className="flex items-center justify-center gap-1">
<Select
value={performanceEmoji || ''}
onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
size="sm"
wrapperClassName="!w-fit mx-auto"
wrapperClassName="!w-fit"
className="!w-16 min-w-16 text-center text-lg py-2.5"
/>
<EvolutionIndicator current={performanceEmoji} previous={previousEntry?.performanceEmoji ?? null} />
</div>
) : (
<div className="text-2xl text-center">{performanceEmoji || '-'}</div>
<div className="flex items-center justify-center gap-1">
<span className="text-2xl">{performanceEmoji || '-'}</span>
<EvolutionIndicator current={performanceEmoji} previous={previousEntry?.performanceEmoji ?? null} />
</div>
)}
</td>
{/* Moral */}
<td className="w-24 px-2 py-3">
<td className="w-28 px-2 py-3">
{canEditThis ? (
<div className="flex items-center justify-center gap-1">
<Select
value={moralEmoji || ''}
onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
size="sm"
wrapperClassName="!w-fit mx-auto"
wrapperClassName="!w-fit"
className="!w-16 min-w-16 text-center text-lg py-2.5"
/>
<EvolutionIndicator current={moralEmoji} previous={previousEntry?.moralEmoji ?? null} />
</div>
) : (
<div className="text-2xl text-center">{moralEmoji || '-'}</div>
<div className="flex items-center justify-center gap-1">
<span className="text-2xl">{moralEmoji || '-'}</span>
<EvolutionIndicator current={moralEmoji} previous={previousEntry?.moralEmoji ?? null} />
</div>
)}
</td>
{/* Flux */}
<td className="w-24 px-2 py-3">
<td className="w-28 px-2 py-3">
{canEditThis ? (
<div className="flex items-center justify-center gap-1">
<Select
value={fluxEmoji || ''}
onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
size="sm"
wrapperClassName="!w-fit mx-auto"
wrapperClassName="!w-fit"
className="!w-16 min-w-16 text-center text-lg py-2.5"
/>
<EvolutionIndicator current={fluxEmoji} previous={previousEntry?.fluxEmoji ?? null} />
</div>
) : (
<div className="text-2xl text-center">{fluxEmoji || '-'}</div>
<div className="flex items-center justify-center gap-1">
<span className="text-2xl">{fluxEmoji || '-'}</span>
<EvolutionIndicator current={fluxEmoji} previous={previousEntry?.fluxEmoji ?? null} />
</div>
)}
</td>
{/* Création de valeur */}
<td className="w-24 px-2 py-3">
<td className="w-28 px-2 py-3">
{canEditThis ? (
<div className="flex items-center justify-center gap-1">
<Select
value={valueCreationEmoji || ''}
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
options={WEATHER_EMOJIS.map(({ emoji }) => ({ value: emoji, label: emoji }))}
size="sm"
wrapperClassName="!w-fit mx-auto"
wrapperClassName="!w-fit"
className="!w-16 min-w-16 text-center text-lg py-2.5"
/>
<EvolutionIndicator current={valueCreationEmoji} previous={previousEntry?.valueCreationEmoji ?? null} />
</div>
) : (
<div className="text-2xl text-center">{valueCreationEmoji || '-'}</div>
<div className="flex items-center justify-center gap-1">
<span className="text-2xl">{valueCreationEmoji || '-'}</span>
<EvolutionIndicator current={valueCreationEmoji} previous={previousEntry?.valueCreationEmoji ?? null} />
</div>
)}
</td>

View File

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

63
src/lib/weather-utils.ts Normal file
View File

@@ -0,0 +1,63 @@
// src/lib/weather-utils.ts
export const WEATHER_EMOJIS = [
{ emoji: '', label: 'Aucun' },
{ emoji: '☀️', label: 'Soleil' },
{ emoji: '🌤️', label: 'Soleil derrière nuage' },
{ emoji: '⛅', label: 'Soleil et nuages' },
{ emoji: '☁️', label: 'Nuages' },
{ emoji: '🌦️', label: 'Soleil et pluie' },
{ emoji: '🌧️', label: 'Pluie' },
{ emoji: '⛈️', label: 'Orage et pluie' },
{ emoji: '🌩️', label: 'Éclair' },
{ emoji: '❄️', label: 'Neige' },
{ emoji: '🌨️', label: 'Neige qui tombe' },
{ emoji: '🌪️', label: 'Tornade' },
{ emoji: '🌫️', label: 'Brouillard' },
{ emoji: '🌈', label: 'Arc-en-ciel' },
{ emoji: '🌊', label: 'Vague' },
{ emoji: '🔥', label: 'Feu' },
{ emoji: '💨', label: 'Vent' },
{ emoji: '⭐', label: 'Étoile' },
{ emoji: '🌟', label: 'Étoile brillante' },
{ emoji: '✨', label: 'Étincelles' },
];
// Score = 1-based index in the list. Empty string = no value (excluded from scoring).
export function getEmojiScore(emoji: string | null | undefined): number | null {
if (!emoji) return null;
const idx = WEATHER_EMOJIS.findIndex((e) => e.emoji === emoji);
if (idx <= 0) return null; // idx 0 = '' (no value), or not found
return idx; // 1 = ☀️ (best), 19 = ✨ (worst)
}
// Returns the emoji whose score is closest to the average of all scored emojis.
// Returns null if no scored emojis.
export function getAverageEmoji(emojis: (string | null | undefined)[]): string | null {
const scores = emojis.map(getEmojiScore).filter((s): s is number => s !== null);
if (scores.length === 0) return null;
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
// Find emoji with index closest to avg (skip index 0 = empty)
let closest: { emoji: string; dist: number } | null = null;
for (let i = 1; i < WEATHER_EMOJIS.length; i++) {
const dist = Math.abs(i - avg);
if (closest === null || dist < closest.dist) {
closest = { emoji: WEATHER_EMOJIS[i].emoji, dist };
}
}
return closest?.emoji ?? null;
}
// Returns evolution direction: 'up' (amélioration), 'down' (dégradation), 'same', or null (no previous).
export function getEmojiEvolution(
current: string | null | undefined,
previous: string | null | undefined
): 'up' | 'down' | 'same' | null {
const currentScore = getEmojiScore(current);
const previousScore = getEmojiScore(previous);
if (currentScore === null || previousScore === null) return null;
const delta = currentScore - previousScore;
if (delta < 0) return 'up'; // lower score = better weather
if (delta > 0) return 'down'; // higher score = worse weather
return 'same';
}

View File

@@ -177,6 +177,78 @@ export async function deleteWeatherEntry(sessionId: string, userId: string) {
});
}
// Returns the most recent WeatherEntry per userId from any session BEFORE sessionDate,
// excluding the current session. Returned as a map userId → entry.
export async function getPreviousWeatherEntriesForUsers(
excludeSessionId: string,
sessionDate: Date,
userIds: string[]
): Promise<
Map<
string,
{
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
}
>
> {
if (userIds.length === 0) return new Map();
const entries = await prisma.weatherEntry.findMany({
where: {
userId: { in: userIds },
sessionId: { not: excludeSessionId },
session: { date: { lt: sessionDate } },
},
select: {
userId: true,
performanceEmoji: true,
moralEmoji: true,
fluxEmoji: true,
valueCreationEmoji: true,
session: { select: { date: true } },
},
});
// Sort by session.date desc (Prisma orderBy on relation is unreliable with SQLite)
entries.sort((a, b) => {
const dateA = a.session.date.getTime();
const dateB = b.session.date.getTime();
if (dateB !== dateA) return dateB - dateA; // most recent first
return a.userId.localeCompare(b.userId);
});
// For each user, use the most recent previous value PER AXIS (fallback if latest session has null)
const map = new Map<
string,
{
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
}
>();
for (const entry of entries) {
const existing = map.get(entry.userId);
const base = existing ?? {
performanceEmoji: null as string | null,
moralEmoji: null as string | null,
fluxEmoji: null as string | null,
valueCreationEmoji: null as string | null,
};
if (!existing) map.set(entry.userId, base);
if (base.performanceEmoji == null && entry.performanceEmoji != null) base.performanceEmoji = entry.performanceEmoji;
if (base.moralEmoji == null && entry.moralEmoji != null) base.moralEmoji = entry.moralEmoji;
if (base.fluxEmoji == null && entry.fluxEmoji != null) base.fluxEmoji = entry.fluxEmoji;
if (base.valueCreationEmoji == null && entry.valueCreationEmoji != null)
base.valueCreationEmoji = entry.valueCreationEmoji;
}
return map;
}
// ============================================
// Session Sharing
// ============================================