Compare commits

..

3 Commits

Author SHA1 Message Date
313ad53e2e refactor(weather): move top disclosures below board
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m3s
2026-03-04 08:39:19 +01:00
8bff21bede feat(weather): show trend indicators on team averages 2026-03-04 08:34:23 +01:00
4aea17124e feat(ui): allow per-disclosure emoji icons 2026-03-04 08:32:19 +01:00
6 changed files with 138 additions and 33 deletions

View File

@@ -325,7 +325,7 @@ export default function DesignSystemPage() {
<Card id="disclosure-dropdown" className="p-6"> <Card id="disclosure-dropdown" className="p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">Disclosure & Dropdown</h2> <h2 className="mb-4 text-xl font-semibold text-foreground">Disclosure & Dropdown</h2>
<div className="space-y-4"> <div className="space-y-4">
<Disclosure title="Panneau pliable" subtitle="Composant Disclosure"> <Disclosure icon="" title="Panneau pliable" subtitle="Composant Disclosure">
<p className="text-sm text-muted">Contenu du panneau.</p> <p className="text-sm text-muted">Contenu du panneau.</p>
</Disclosure> </Disclosure>
<DropdownMenu <DropdownMenu

View File

@@ -40,6 +40,16 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
getUserTeams(authSession.user.id), getUserTeams(authSession.user.id),
getWeatherSessionsHistory(authSession.user.id), getWeatherSessionsHistory(authSession.user.id),
]); ]);
const currentHistoryIndex = history.findIndex((point) => point.sessionId === session.id);
const previousTeamAverages =
currentHistoryIndex > 0
? {
performance: history[currentHistoryIndex - 1].performance,
moral: history[currentHistoryIndex - 1].moral,
flux: history[currentHistoryIndex - 1].flux,
valueCreation: history[currentHistoryIndex - 1].valueCreation,
}
: null;
return ( return (
<main className="mx-auto max-w-7xl px-4"> <main className="mx-auto max-w-7xl px-4">
@@ -54,12 +64,6 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
badges={<Badge variant="primary">{session.entries.length} membres</Badge>} badges={<Badge variant="primary">{session.entries.length} membres</Badge>}
/> />
{/* Info sur les catégories */}
<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}
@@ -70,7 +74,7 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
canEdit={session.canEdit} canEdit={session.canEdit}
userTeams={userTeams} userTeams={userTeams}
> >
<WeatherAverageBar entries={session.entries} /> <WeatherAverageBar entries={session.entries} previousAverages={previousTeamAverages} />
<WeatherBoard <WeatherBoard
sessionId={session.id} sessionId={session.id}
currentUserId={authSession.user.id} currentUserId={authSession.user.id}
@@ -84,6 +88,8 @@ export default async function WeatherSessionPage({ params }: WeatherSessionPageP
canEdit={session.canEdit} canEdit={session.canEdit}
previousEntries={Object.fromEntries(previousEntries)} previousEntries={Object.fromEntries(previousEntries)}
/> />
<WeatherInfoPanel className="mt-6 mb-6" />
<WeatherTrendChart data={history} currentSessionId={session.id} />
</WeatherLiveWrapper> </WeatherLiveWrapper>
</main> </main>
); );

View File

@@ -3,6 +3,7 @@
import { ReactNode, useId, useState } from 'react'; import { ReactNode, useId, useState } from 'react';
interface DisclosureProps { interface DisclosureProps {
icon?: ReactNode;
title: ReactNode; title: ReactNode;
subtitle?: ReactNode; subtitle?: ReactNode;
defaultOpen?: boolean; defaultOpen?: boolean;
@@ -11,6 +12,7 @@ interface DisclosureProps {
} }
export function Disclosure({ export function Disclosure({
icon,
title, title,
subtitle, subtitle,
defaultOpen = false, defaultOpen = false,
@@ -21,29 +23,41 @@ export function Disclosure({
const contentId = useId(); const contentId = useId();
return ( return (
<div className={`rounded-lg border border-border bg-card-hover ${className}`}> <div className={`overflow-hidden rounded-xl border border-border bg-card ${className}`}>
<button <button
type="button" type="button"
onClick={() => setIsOpen((prev) => !prev)} onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen} aria-expanded={isOpen}
aria-controls={contentId} aria-controls={contentId}
className="flex w-full items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-card" className={`
flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors
${isOpen ? 'bg-card-hover/60' : 'hover:bg-card-hover/60'}
`}
> >
<div className="min-w-0 flex flex-1 items-start gap-2">
{icon && (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center self-center text-base leading-none">
{icon}
</span>
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-foreground">{title}</div> <div className="text-sm font-medium text-foreground">{title}</div>
{subtitle && <div className="text-xs text-muted">{subtitle}</div>} {subtitle && <div className="mt-0.5 text-xs text-muted">{subtitle}</div>}
</div> </div>
</div>
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-card-hover text-muted">
<svg <svg
className={`h-4 w-4 shrink-0 text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</span>
</button> </button>
{isOpen && ( {isOpen && (
<div id={contentId} className="border-t border-border px-4 py-3"> <div id={contentId} className="border-t border-border bg-card px-4 py-3">
{children} {children}
</div> </div>
)} )}

View File

@@ -1,5 +1,5 @@
// src/components/weather/WeatherAverageBar.tsx // src/components/weather/WeatherAverageBar.tsx
import { getAverageEmoji } from '@/lib/weather-utils'; import { getAverageEmoji, getEmojiScore } from '@/lib/weather-utils';
interface WeatherEntry { interface WeatherEntry {
performanceEmoji: string | null; performanceEmoji: string | null;
@@ -10,22 +10,93 @@ interface WeatherEntry {
interface WeatherAverageBarProps { interface WeatherAverageBarProps {
entries: WeatherEntry[]; entries: WeatherEntry[];
previousAverages?: {
performance: number | null;
moral: number | null;
flux: number | null;
valueCreation: number | null;
} | null;
} }
const AXES = [ const AXES = [
{ key: 'performanceEmoji' as const, label: 'Performance' }, { key: 'performanceEmoji' as const, scoreKey: 'performance' as const, label: 'Performance' },
{ key: 'moralEmoji' as const, label: 'Moral' }, { key: 'moralEmoji' as const, scoreKey: 'moral' as const, label: 'Moral' },
{ key: 'fluxEmoji' as const, label: 'Flux' }, { key: 'fluxEmoji' as const, scoreKey: 'flux' as const, label: 'Flux' },
{ key: 'valueCreationEmoji' as const, label: 'Création de valeur' }, {
key: 'valueCreationEmoji' as const,
scoreKey: 'valueCreation' as const,
label: 'Création de valeur',
},
]; ];
export function WeatherAverageBar({ entries }: WeatherAverageBarProps) { function getAverageScore(emojis: (string | null)[]): number | null {
const scores = emojis.map(getEmojiScore).filter((score): score is number => score !== null);
if (scores.length === 0) return null;
return scores.reduce((sum, score) => sum + score, 0) / scores.length;
}
function AverageEvolutionIndicator({
currentScore,
previousScore,
}: {
currentScore: number | null;
previousScore: number | null | undefined;
}) {
if (currentScore === null || previousScore === null || previousScore === undefined) return null;
const delta = currentScore - previousScore;
if (delta < 0) {
return (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
style={{
backgroundColor: 'color-mix(in srgb, var(--success) 18%, transparent)',
color: 'var(--success)',
}}
title="En progression"
>
</span>
);
}
if (delta > 0) {
return (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
style={{
backgroundColor: 'color-mix(in srgb, var(--destructive) 18%, transparent)',
color: 'var(--destructive)',
}}
title="En baisse"
>
</span>
);
}
return (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
style={{
backgroundColor: 'color-mix(in srgb, var(--muted) 15%, transparent)',
color: 'var(--muted)',
}}
title="Stable"
>
</span>
);
}
export function WeatherAverageBar({ entries, previousAverages }: WeatherAverageBarProps) {
if (entries.length === 0) return null; if (entries.length === 0) return null;
return ( return (
<div className="flex flex-wrap items-center gap-3 mb-4"> <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> <span className="text-xs font-medium text-muted uppercase tracking-wide">Moyenne équipe</span>
{AXES.map(({ key, label }) => { {AXES.map(({ key, scoreKey, label }) => {
const currentScore = getAverageScore(entries.map((e) => e[key]));
const avg = getAverageEmoji(entries.map((e) => e[key])); const avg = getAverageEmoji(entries.map((e) => e[key]));
return ( return (
<div <div
@@ -34,6 +105,10 @@ export function WeatherAverageBar({ entries }: WeatherAverageBarProps) {
> >
<span className="text-lg leading-none">{avg ?? '—'}</span> <span className="text-lg leading-none">{avg ?? '—'}</span>
<span className="text-xs text-muted">{label}</span> <span className="text-xs text-muted">{label}</span>
<AverageEvolutionIndicator
currentScore={currentScore}
previousScore={previousAverages?.[scoreKey]}
/>
</div> </div>
); );
})} })}

View File

@@ -2,9 +2,13 @@
import { Disclosure } from '@/components/ui'; import { Disclosure } from '@/components/ui';
export function WeatherInfoPanel() { interface WeatherInfoPanelProps {
className?: string;
}
export function WeatherInfoPanel({ className = 'mb-6' }: WeatherInfoPanelProps) {
return ( return (
<Disclosure title="Les 4 axes de la météo personnelle" className="mb-6"> <Disclosure icon="" title="Les 4 axes de la météo personnelle" className={className}>
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
<div> <div>
<p className="mb-0.5 text-xs font-medium text-foreground"> Performance</p> <p className="mb-0.5 text-xs font-medium text-foreground"> Performance</p>

View File

@@ -7,6 +7,7 @@ import type { WeatherHistoryPoint } from '@/services/weather';
interface WeatherTrendChartProps { interface WeatherTrendChartProps {
data: WeatherHistoryPoint[]; data: WeatherHistoryPoint[];
currentSessionId?: string; currentSessionId?: string;
className?: string;
} }
const INDICATORS = [ const INDICATORS = [
@@ -63,7 +64,11 @@ function buildPath(
const Y_TICKS = [1, 5, 10, 14, 19]; const Y_TICKS = [1, 5, 10, 14, 19];
export function WeatherTrendChart({ data, currentSessionId }: WeatherTrendChartProps) { export function WeatherTrendChart({
data,
currentSessionId,
className = 'mb-6',
}: WeatherTrendChartProps) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null); const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
if (data.length < 2) return null; if (data.length < 2) return null;
@@ -72,7 +77,8 @@ export function WeatherTrendChart({ data, currentSessionId }: WeatherTrendChartP
return ( return (
<Disclosure <Disclosure
className="mb-6" icon="📈"
className={className}
title={ title={
<> <>
Évolution dans le temps Évolution dans le temps