From 163caa398c00a963982e07d3d86f07b1b8be78d6 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 3 Feb 2026 18:08:06 +0100 Subject: [PATCH] feat: implement Weather Workshop feature with models, UI components, and session management for enhanced team visibility and personal well-being tracking --- prisma/schema.prisma | 71 ++++ src/actions/weather.ts | 225 ++++++++++ src/app/api/weather/[id]/subscribe/route.ts | 122 ++++++ src/app/page.tsx | 104 +++++ src/app/sessions/WorkshopTabs.tsx | 108 ++++- src/app/sessions/page.tsx | 29 +- src/app/weather/[id]/page.tsx | 102 +++++ src/app/weather/new/page.tsx | 142 +++++++ src/app/weekly-checkin/new/page.tsx | 14 +- src/components/ui/EditableWeatherTitle.tsx | 28 ++ src/components/ui/index.ts | 1 + src/components/weather/WeatherBoard.tsx | 146 +++++++ src/components/weather/WeatherCard.tsx | 273 ++++++++++++ src/components/weather/WeatherInfoPanel.tsx | 56 +++ src/components/weather/WeatherLiveWrapper.tsx | 142 +++++++ src/components/weather/WeatherShareModal.tsx | 307 ++++++++++++++ src/components/weather/index.ts | 5 + src/hooks/useWeatherLive.ts | 31 ++ src/lib/date-utils.ts | 21 + src/services/weather.ts | 388 ++++++++++++++++++ 20 files changed, 2287 insertions(+), 28 deletions(-) create mode 100644 src/actions/weather.ts create mode 100644 src/app/api/weather/[id]/subscribe/route.ts create mode 100644 src/app/weather/[id]/page.tsx create mode 100644 src/app/weather/new/page.tsx create mode 100644 src/components/ui/EditableWeatherTitle.tsx create mode 100644 src/components/weather/WeatherBoard.tsx create mode 100644 src/components/weather/WeatherCard.tsx create mode 100644 src/components/weather/WeatherInfoPanel.tsx create mode 100644 src/components/weather/WeatherLiveWrapper.tsx create mode 100644 src/components/weather/WeatherShareModal.tsx create mode 100644 src/components/weather/index.ts create mode 100644 src/hooks/useWeatherLive.ts create mode 100644 src/lib/date-utils.ts create mode 100644 src/services/weather.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09131f2..783d6b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,11 @@ model User { weeklyCheckInSessions WeeklyCheckInSession[] sharedWeeklyCheckInSessions WCISessionShare[] weeklyCheckInSessionEvents WCISessionEvent[] + // Weather Workshop relations + weatherSessions WeatherSession[] + sharedWeatherSessions WeatherSessionShare[] + weatherSessionEvents WeatherSessionEvent[] + weatherEntries WeatherEntry[] // Teams & OKRs relations createdTeams Team[] teamMembers TeamMember[] @@ -454,3 +459,69 @@ model WCISessionEvent { @@index([sessionId, createdAt]) } + +// ============================================ +// Weather Workshop +// ============================================ + +model WeatherSession { + id String @id @default(cuid()) + title String + date DateTime @default(now()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + entries WeatherEntry[] + shares WeatherSessionShare[] + events WeatherSessionEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([date]) +} + +model WeatherEntry { + id String @id @default(cuid()) + sessionId String + session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + performanceEmoji String? // Emoji météo pour Performance + moralEmoji String? // Emoji météo pour Moral + fluxEmoji String? // Emoji météo pour Flux + valueCreationEmoji String? // Emoji météo pour Création de valeur + notes String? // Notes globales + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([sessionId, userId]) // Un seul entry par membre par session + @@index([sessionId]) + @@index([userId]) +} + +model WeatherSessionShare { + id String @id @default(cuid()) + sessionId String + session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role ShareRole @default(EDITOR) + createdAt DateTime @default(now()) + + @@unique([sessionId, userId]) + @@index([sessionId]) + @@index([userId]) +} + +model WeatherSessionEvent { + id String @id @default(cuid()) + sessionId String + session WeatherSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // ENTRY_CREATED, ENTRY_UPDATED, ENTRY_DELETED, SESSION_UPDATED, etc. + payload String // JSON payload + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/src/actions/weather.ts b/src/actions/weather.ts new file mode 100644 index 0000000..4342912 --- /dev/null +++ b/src/actions/weather.ts @@ -0,0 +1,225 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { auth } from '@/lib/auth'; +import * as weatherService from '@/services/weather'; + +// ============================================ +// Session Actions +// ============================================ + +export async function createWeatherSession(data: { title: string; date?: Date }) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const weatherSession = await weatherService.createWeatherSession(session.user.id, data); + revalidatePath('/weather'); + revalidatePath('/sessions'); + return { success: true, data: weatherSession }; + } catch (error) { + console.error('Error creating weather session:', error); + return { success: false, error: 'Erreur lors de la création' }; + } +} + +export async function updateWeatherSession( + sessionId: string, + data: { title?: string; date?: Date } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await weatherService.updateWeatherSession(sessionId, authSession.user.id, data); + + // Emit event for real-time sync + await weatherService.createWeatherSessionEvent( + sessionId, + authSession.user.id, + 'SESSION_UPDATED', + data + ); + + revalidatePath(`/weather/${sessionId}`); + revalidatePath('/weather'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error updating weather session:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteWeatherSession(sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await weatherService.deleteWeatherSession(sessionId, authSession.user.id); + revalidatePath('/weather'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error deleting weather session:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +// ============================================ +// Entry Actions +// ============================================ + +export async function createOrUpdateWeatherEntry( + sessionId: string, + data: { + performanceEmoji?: string | null; + moralEmoji?: string | null; + fluxEmoji?: string | null; + valueCreationEmoji?: string | null; + notes?: string | null; + } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + const entry = await weatherService.createOrUpdateWeatherEntry(sessionId, authSession.user.id, data); + + // Emit event for real-time sync + const eventType = entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED'; + await weatherService.createWeatherSessionEvent( + sessionId, + authSession.user.id, + eventType, + { + entryId: entry.id, + userId: entry.userId, + ...data, + } + ); + + revalidatePath(`/weather/${sessionId}`); + return { success: true, data: entry }; + } catch (error) { + console.error('Error creating/updating weather entry:', error); + return { success: false, error: 'Erreur lors de la sauvegarde' }; + } +} + +export async function deleteWeatherEntry(sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await weatherService.canEditWeatherSession(sessionId, authSession.user.id); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await weatherService.deleteWeatherEntry(sessionId, authSession.user.id); + + // Emit event for real-time sync + await weatherService.createWeatherSessionEvent( + sessionId, + authSession.user.id, + 'ENTRY_DELETED', + { userId: authSession.user.id } + ); + + revalidatePath(`/weather/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error deleting weather entry:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +// ============================================ +// Sharing Actions +// ============================================ + +export async function shareWeatherSession( + sessionId: string, + targetEmail: string, + role: 'VIEWER' | 'EDITOR' = 'EDITOR' +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const share = await weatherService.shareWeatherSession( + sessionId, + authSession.user.id, + targetEmail, + role + ); + revalidatePath(`/weather/${sessionId}`); + return { success: true, data: share }; + } catch (error) { + console.error('Error sharing weather session:', error); + const message = error instanceof Error ? error.message : 'Erreur lors du partage'; + return { success: false, error: message }; + } +} + +export async function shareWeatherSessionToTeam( + sessionId: string, + teamId: string, + role: 'VIEWER' | 'EDITOR' = 'EDITOR' +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const shares = await weatherService.shareWeatherSessionToTeam( + sessionId, + authSession.user.id, + teamId, + role + ); + revalidatePath(`/weather/${sessionId}`); + return { success: true, data: shares }; + } catch (error) { + console.error('Error sharing weather session to team:', error); + const message = error instanceof Error ? error.message : 'Erreur lors du partage à l\'équipe'; + return { success: false, error: message }; + } +} + +export async function removeWeatherShare(sessionId: string, shareUserId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await weatherService.removeWeatherShare(sessionId, authSession.user.id, shareUserId); + revalidatePath(`/weather/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error removing weather share:', error); + return { success: false, error: 'Erreur lors de la suppression du partage' }; + } +} diff --git a/src/app/api/weather/[id]/subscribe/route.ts b/src/app/api/weather/[id]/subscribe/route.ts new file mode 100644 index 0000000..89598a6 --- /dev/null +++ b/src/app/api/weather/[id]/subscribe/route.ts @@ -0,0 +1,122 @@ +import { auth } from '@/lib/auth'; +import { + canAccessWeatherSession, + getWeatherSessionEvents, +} from '@/services/weather'; + +export const dynamic = 'force-dynamic'; + +// Store active connections per session +const connections = new Map>(); + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id: sessionId } = await params; + const session = await auth(); + + if (!session?.user?.id) { + return new Response('Unauthorized', { status: 401 }); + } + + // Check access + const hasAccess = await canAccessWeatherSession(sessionId, session.user.id); + if (!hasAccess) { + return new Response('Forbidden', { status: 403 }); + } + + const userId = session.user.id; + let lastEventTime = new Date(); + let controller: ReadableStreamDefaultController; + + const stream = new ReadableStream({ + start(ctrl) { + controller = ctrl; + + // Register connection + if (!connections.has(sessionId)) { + connections.set(sessionId, new Set()); + } + connections.get(sessionId)!.add(controller); + + // Send initial ping + const encoder = new TextEncoder(); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) + ); + }, + cancel() { + // Remove connection on close + connections.get(sessionId)?.delete(controller); + if (connections.get(sessionId)?.size === 0) { + connections.delete(sessionId); + } + }, + }); + + // Poll for new events (simple approach, works with any DB) + const pollInterval = setInterval(async () => { + try { + const events = await getWeatherSessionEvents(sessionId, lastEventTime); + if (events.length > 0) { + const encoder = new TextEncoder(); + for (const event of events) { + // Don't send events to the user who created them + if (event.userId !== userId) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, + })}\n\n` + ) + ); + } + lastEventTime = event.createdAt; + } + } + } catch { + // Connection might be closed + clearInterval(pollInterval); + } + }, 1000); // Poll every second + + // Cleanup on abort + request.signal.addEventListener('abort', () => { + clearInterval(pollInterval); + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +// Helper to broadcast to all connections (called from actions) +export function broadcastToWeatherSession(sessionId: string, event: object) { + const sessionConnections = connections.get(sessionId); + if (!sessionConnections || sessionConnections.size === 0) { + return; + } + + const encoder = new TextEncoder(); + const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); + + for (const controller of sessionConnections) { + try { + controller.enqueue(message); + } catch { + // Connection might be closed, remove it + sessionConnections.delete(controller); + } + } + + // Clean up empty sets + if (sessionConnections.size === 0) { + connections.delete(sessionId); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index fada1d9..716b2d1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -84,6 +84,22 @@ export default function Home() { accentColor="#10b981" newHref="/weekly-checkin/new" /> + + {/* Weather Workshop Card */} + @@ -459,6 +475,94 @@ export default function Home() { + {/* Weather Deep Dive Section */} +
+
+ 🌤️ +
+

Météo

+

Votre état en un coup d'œil

+
+
+ +
+ {/* Why */} +
+

+ 💡 + Pourquoi créer une météo personnelle ? +

+

+ La météo est un outil simple et visuel pour exprimer rapidement votre état sur 4 axes clés. + En la partageant avec votre équipe, vous créez de la transparence et facilitez la communication + sur votre bien-être et votre performance. +

+
    +
  • + + Exprimer rapidement votre état avec des emojis météo intuitifs +
  • +
  • + + Partager votre météo avec votre équipe pour une meilleure visibilité +
  • +
  • + + Créer un espace de dialogue ouvert sur votre performance et votre moral +
  • +
  • + + Suivre l'évolution de votre état dans le temps +
  • +
+
+ + {/* The 4 axes */} +
+

+ 📋 + Les 4 axes de la météo +

+
+ + + + +
+
+ + {/* How it works */} +
+

+ ⚙️ + Comment ça marche ? +

+
+ + + + +
+
+
+
+ {/* OKRs Deep Dive Section */}
diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx index 463ab1b..dda415f 100644 --- a/src/app/sessions/WorkshopTabs.tsx +++ b/src/app/sessions/WorkshopTabs.tsx @@ -16,8 +16,9 @@ import { deleteSwotSession, updateSwotSession } from '@/actions/session'; import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators'; import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review'; import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin'; +import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather'; -type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'weekly-checkin' | 'byPerson'; +type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'weekly-checkin' | 'weather' | 'byPerson'; const VALID_TABS: WorkshopType[] = [ 'all', @@ -25,6 +26,7 @@ const VALID_TABS: WorkshopType[] = [ 'motivators', 'year-review', 'weekly-checkin', + 'weather', 'byPerson', ]; @@ -107,13 +109,27 @@ interface WeeklyCheckInSession { workshopType: 'weekly-checkin'; } -type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession; +interface WeatherSession { + id: string; + title: string; + date: Date; + updatedAt: Date; + isOwner: boolean; + role: 'OWNER' | 'VIEWER' | 'EDITOR'; + user: { id: string; name: string | null; email: string }; + shares: Share[]; + _count: { entries: number }; + workshopType: 'weather'; +} + +type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession | WeatherSession; interface WorkshopTabsProps { swotSessions: SwotSession[]; motivatorSessions: MotivatorSession[]; yearReviewSessions: YearReviewSession[]; weeklyCheckInSessions: WeeklyCheckInSession[]; + weatherSessions: WeatherSession[]; } // Helper to get resolved collaborator from any session @@ -124,6 +140,17 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator { return (session as YearReviewSession).resolvedParticipant; } else if (session.workshopType === 'weekly-checkin') { return (session as WeeklyCheckInSession).resolvedParticipant; + } else if (session.workshopType === 'weather') { + // For weather sessions, use the owner as the "participant" since it's a personal weather + const weatherSession = session as WeatherSession; + return { + raw: weatherSession.user.name || weatherSession.user.email, + matchedUser: { + id: weatherSession.user.id, + email: weatherSession.user.email, + name: weatherSession.user.name, + }, + }; } else { return (session as MotivatorSession).resolvedParticipant; } @@ -168,6 +195,7 @@ export function WorkshopTabs({ motivatorSessions, yearReviewSessions, weeklyCheckInSessions, + weatherSessions, }: WorkshopTabsProps) { const searchParams = useSearchParams(); const router = useRouter(); @@ -193,6 +221,7 @@ export function WorkshopTabs({ ...motivatorSessions, ...yearReviewSessions, ...weeklyCheckInSessions, + ...weatherSessions, ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); // Filter based on active tab (for non-byPerson tabs) @@ -205,7 +234,9 @@ export function WorkshopTabs({ ? motivatorSessions : activeTab === 'year-review' ? yearReviewSessions - : weeklyCheckInSessions; + : activeTab === 'weekly-checkin' + ? weeklyCheckInSessions + : weatherSessions; // Separate by ownership const ownedSessions = filteredSessions.filter((s) => s.isOwner); @@ -263,6 +294,13 @@ export function WorkshopTabs({ label="Weekly Check-in" count={weeklyCheckInSessions.length} /> + setActiveTab('weather')} + icon="🌤️" + label="Météo" + count={weatherSessions.length} + />
{/* Sessions */} @@ -375,34 +413,43 @@ function SessionCard({ session }: { session: AnySession }) { ? (session as SwotSession).collaborator : session.workshopType === 'year-review' ? (session as YearReviewSession).participant - : (session as MotivatorSession).participant + : session.workshopType === 'weather' + ? '' + : (session as MotivatorSession).participant ); const isSwot = session.workshopType === 'swot'; const isYearReview = session.workshopType === 'year-review'; const isWeeklyCheckIn = session.workshopType === 'weekly-checkin'; + const isWeather = session.workshopType === 'weather'; const href = isSwot ? `/sessions/${session.id}` : isYearReview ? `/year-review/${session.id}` : isWeeklyCheckIn ? `/weekly-checkin/${session.id}` - : `/motivators/${session.id}`; - const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : '🎯'; + : isWeather + ? `/weather/${session.id}` + : `/motivators/${session.id}`; + const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : isWeather ? '🌤️' : '🎯'; const participant = isSwot ? (session as SwotSession).collaborator : isYearReview ? (session as YearReviewSession).participant : isWeeklyCheckIn ? (session as WeeklyCheckInSession).participant - : (session as MotivatorSession).participant; + : isWeather + ? (session as WeatherSession).user.name || (session as WeatherSession).user.email + : (session as MotivatorSession).participant; const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : isWeeklyCheckIn ? '#10b981' - : '#8b5cf6'; + : isWeather + ? '#3b82f6' + : '#8b5cf6'; const handleDelete = () => { startTransition(async () => { @@ -412,7 +459,9 @@ function SessionCard({ session }: { session: AnySession }) { ? await deleteYearReviewSession(session.id) : isWeeklyCheckIn ? await deleteWeeklyCheckInSession(session.id) - : await deleteMotivatorSession(session.id); + : isWeather + ? await deleteWeatherSession(session.id) + : await deleteMotivatorSession(session.id); if (result.success) { setShowDeleteModal(false); @@ -436,10 +485,12 @@ function SessionCard({ session }: { session: AnySession }) { title: editTitle, participant: editParticipant, }) - : await updateMotivatorSession(session.id, { - title: editTitle, - participant: editParticipant, - }); + : isWeather + ? await updateWeatherSession(session.id, { title: editTitle }) + : await updateMotivatorSession(session.id, { + title: editTitle, + participant: editParticipant, + }); if (result.success) { setShowEditModal(false); @@ -456,7 +507,7 @@ function SessionCard({ session }: { session: AnySession }) { setShowEditModal(true); }; - const editParticipantLabel = isSwot ? 'Collaborateur' : 'Participant'; + const editParticipantLabel = isSwot ? 'Collaborateur' : isWeather ? '' : 'Participant'; return ( <> @@ -524,6 +575,17 @@ function SessionCard({ session }: { session: AnySession }) { })} + ) : isWeather ? ( + <> + {(session as WeatherSession)._count.entries} membres + · + + {new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + + ) : ( {(session as MotivatorSession)._count.cards}/10 )} @@ -640,13 +702,15 @@ function SessionCard({ session }: { session: AnySession }) { > {editParticipantLabel} - setEditParticipant(e.target.value)} - placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} - required - /> + {!isWeather && ( + setEditParticipant(e.target.value)} + placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} + required + /> + )} diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index 00b171e..9882568 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -5,6 +5,7 @@ import { getSessionsByUserId } from '@/services/sessions'; import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; import { getYearReviewSessionsByUserId } from '@/services/year-review'; import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin'; +import { getWeatherSessionsByUserId } from '@/services/weather'; import { Card, Button } from '@/components/ui'; import { WorkshopTabs } from './WorkshopTabs'; @@ -34,13 +35,14 @@ export default async function SessionsPage() { return null; } - // Fetch SWOT, Moving Motivators, Year Review, and Weekly Check-in sessions - const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions] = + // Fetch SWOT, Moving Motivators, Year Review, Weekly Check-in, and Weather sessions + const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] = await Promise.all([ getSessionsByUserId(session.user.id), getMotivatorSessionsByUserId(session.user.id), getYearReviewSessionsByUserId(session.user.id), getWeeklyCheckInSessionsByUserId(session.user.id), + getWeatherSessionsByUserId(session.user.id), ]); // Add type to each session for unified display @@ -64,12 +66,18 @@ export default async function SessionsPage() { workshopType: 'weekly-checkin' as const, })); + const allWeatherSessions = weatherSessions.map((s) => ({ + ...s, + workshopType: 'weather' as const, + })); + // Combine and sort by updatedAt const allSessions = [ ...allSwotSessions, ...allMotivatorSessions, ...allYearReviewSessions, ...allWeeklyCheckInSessions, + ...allWeatherSessions, ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); const hasNoSessions = allSessions.length === 0; @@ -102,11 +110,17 @@ export default async function SessionsPage() { - + + + @@ -142,11 +156,17 @@ export default async function SessionsPage() { - + + + ) : ( @@ -156,6 +176,7 @@ export default async function SessionsPage() { motivatorSessions={allMotivatorSessions} yearReviewSessions={allYearReviewSessions} weeklyCheckInSessions={allWeeklyCheckInSessions} + weatherSessions={allWeatherSessions} /> )} diff --git a/src/app/weather/[id]/page.tsx b/src/app/weather/[id]/page.tsx new file mode 100644 index 0000000..b18f89c --- /dev/null +++ b/src/app/weather/[id]/page.tsx @@ -0,0 +1,102 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { getWeatherSessionById } from '@/services/weather'; +import { getUserTeams } from '@/services/teams'; +import { WeatherBoard, WeatherLiveWrapper, WeatherInfoPanel } from '@/components/weather'; +import { Badge } from '@/components/ui'; +import { EditableWeatherTitle } from '@/components/ui/EditableWeatherTitle'; + +interface WeatherSessionPageProps { + params: Promise<{ id: string }>; +} + +export default async function WeatherSessionPage({ params }: WeatherSessionPageProps) { + const { id } = await params; + const authSession = await auth(); + + if (!authSession?.user?.id) { + return null; + } + + const [session, userTeams] = await Promise.all([ + getWeatherSessionById(id, authSession.user.id), + getUserTeams(authSession.user.id), + ]); + + if (!session) { + notFound(); + } + + return ( +
+ {/* Header */} +
+
+ + Météo + + / + {session.title} + {!session.isOwner && ( + + Partagé par {session.user.name || session.user.email} + + )} +
+ +
+
+ +
+
+ {session.entries.length} membres + + {new Date(session.date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+
+ + {/* Info sur les catégories */} + + + {/* Live Wrapper + Board */} + + + +
+ ); +} diff --git a/src/app/weather/new/page.tsx b/src/app/weather/new/page.tsx new file mode 100644 index 0000000..b1bf2be --- /dev/null +++ b/src/app/weather/new/page.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Button, + Input, +} from '@/components/ui'; +import { createWeatherSession } from '@/actions/weather'; +import { getWeekYearLabel } from '@/lib/date-utils'; + +export default function NewWeatherPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); + const [title, setTitle] = useState(''); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + const formData = new FormData(e.currentTarget); + const dateStr = formData.get('date') as string; + const date = dateStr ? new Date(dateStr) : undefined; + + if (!title) { + setError('Veuillez remplir le titre'); + setLoading(false); + return; + } + + const result = await createWeatherSession({ title, date }); + + if (!result.success) { + setError(result.error || 'Une erreur est survenue'); + setLoading(false); + return; + } + + router.push(`/weather/${result.data?.id}`); + } + + // Default date to today + const today = new Date().toISOString().split('T')[0]; + + // Update title when date changes + useEffect(() => { + setTitle(getWeekYearLabel(new Date(selectedDate))); + }, [selectedDate]); + + return ( +
+ + + + 🌤️ + Nouvelle Météo + + + Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec votre équipe + + + + +
+ {error && ( +
+ {error} +
+ )} + + setTitle(e.target.value)} + required + /> + +
+ + setSelectedDate(e.target.value)} + required + className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> +
+ +
+

Comment ça marche ?

+
    +
  1. + Performance : Comment évaluez-vous votre performance personnelle ? +
  2. +
  3. + Moral : Quel est votre moral actuel ? +
  4. +
  5. + Flux : Comment se passe votre flux de travail personnel ? +
  6. +
  7. + Création de valeur : Comment évaluez-vous votre création de valeur ? +
  8. +
+

+ 💡 Astuce : Partagez votre météo avec votre équipe pour qu'ils puissent voir votre état. Chaque membre peut créer sa propre météo et la partager ! +

+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/weekly-checkin/new/page.tsx b/src/app/weekly-checkin/new/page.tsx index 56490f7..3529419 100644 --- a/src/app/weekly-checkin/new/page.tsx +++ b/src/app/weekly-checkin/new/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Card, @@ -12,11 +12,14 @@ import { Input, } from '@/components/ui'; import { createWeeklyCheckInSession } from '@/actions/weekly-checkin'; +import { getWeekYearLabel } from '@/lib/date-utils'; export default function NewWeeklyCheckInPage() { const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); + const [title, setTitle] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -24,7 +27,6 @@ export default function NewWeeklyCheckInPage() { setLoading(true); const formData = new FormData(e.currentTarget); - const title = formData.get('title') as string; const participant = formData.get('participant') as string; const dateStr = formData.get('date') as string; const date = dateStr ? new Date(dateStr) : undefined; @@ -49,6 +51,11 @@ export default function NewWeeklyCheckInPage() { // Default date to today const today = new Date().toISOString().split('T')[0]; + // Update title when date changes + useEffect(() => { + setTitle(getWeekYearLabel(new Date(selectedDate))); + }, [selectedDate]); + return (
@@ -75,6 +82,8 @@ export default function NewWeeklyCheckInPage() { label="Titre du check-in" name="title" placeholder="Ex: Check-in semaine du 15 janvier" + value={title} + onChange={(e) => setTitle(e.target.value)} required /> @@ -94,6 +103,7 @@ export default function NewWeeklyCheckInPage() { name="date" type="date" defaultValue={today} + onChange={(e) => setSelectedDate(e.target.value)} required className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" /> diff --git a/src/components/ui/EditableWeatherTitle.tsx b/src/components/ui/EditableWeatherTitle.tsx new file mode 100644 index 0000000..72c9018 --- /dev/null +++ b/src/components/ui/EditableWeatherTitle.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { EditableTitle } from './EditableTitle'; +import { updateWeatherSession } from '@/actions/weather'; + +interface EditableWeatherTitleProps { + sessionId: string; + initialTitle: string; + isOwner: boolean; +} + +export function EditableWeatherTitle({ + sessionId, + initialTitle, + isOwner, +}: EditableWeatherTitleProps) { + return ( + { + const result = await updateWeatherSession(id, { title }); + return result; + }} + /> + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index eb8d700..0697aa1 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -8,6 +8,7 @@ export { EditableSessionTitle } from './EditableSessionTitle'; export { EditableMotivatorTitle } from './EditableMotivatorTitle'; export { EditableYearReviewTitle } from './EditableYearReviewTitle'; export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle'; +export { EditableWeatherTitle } from './EditableWeatherTitle'; export { Input } from './Input'; export { Modal, ModalFooter } from './Modal'; export { Select } from './Select'; diff --git a/src/components/weather/WeatherBoard.tsx b/src/components/weather/WeatherBoard.tsx new file mode 100644 index 0000000..acab0ce --- /dev/null +++ b/src/components/weather/WeatherBoard.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useMemo } from 'react'; +import { WeatherCard } from './WeatherCard'; + +interface WeatherEntry { + id: string; + userId: string; + performanceEmoji: string | null; + moralEmoji: string | null; + fluxEmoji: string | null; + valueCreationEmoji: string | null; + notes: string | null; + user: { + id: string; + name: string | null; + email: string; + }; +} + +interface Share { + id: string; + userId: string; + user: { + id: string; + name: string | null; + email: string; + }; +} + +interface WeatherBoardProps { + sessionId: string; + currentUserId: string; + currentUser: { + id: string; + name: string | null; + email: string; + }; + entries: WeatherEntry[]; + shares: Share[]; + owner: { + id: string; + name: string | null; + email: string; + }; + canEdit: boolean; +} + +export function WeatherBoard({ + sessionId, + currentUserId, + entries, + shares, + owner, + canEdit, +}: WeatherBoardProps) { + // Get all users who have access: owner + shared users + const allUsers = useMemo(() => { + const usersMap = new Map(); + + // Add owner + usersMap.set(owner.id, owner); + + // Add shared users + shares.forEach((share) => { + usersMap.set(share.userId, share.user); + }); + + return Array.from(usersMap.values()); + }, [owner, shares]); + + // Create entries map for quick lookup + const entriesMap = useMemo(() => { + const map = new Map(); + entries.forEach((entry) => { + map.set(entry.userId, entry); + }); + return map; + }, [entries]); + + // Create entries for all users (with placeholder entries for users without entries) + const allEntries = useMemo(() => { + return allUsers.map((user) => { + const existingEntry = entriesMap.get(user.id); + if (existingEntry) { + return existingEntry; + } + // Create placeholder entry for user without entry + return { + id: '', + userId: user.id, + performanceEmoji: null, + moralEmoji: null, + fluxEmoji: null, + valueCreationEmoji: null, + notes: null, + user, + }; + }); + }, [allUsers, entriesMap]); + + // Sort: current user first, then owner, then others + const sortedEntries = useMemo(() => { + return [...allEntries].sort((a, b) => { + if (a.userId === currentUserId) return -1; + if (b.userId === currentUserId) return 1; + if (a.userId === owner.id) return -1; + if (b.userId === owner.id) return 1; + return (a.user.name || a.user.email).localeCompare(b.user.name || b.user.email, 'fr'); + }); + }, [allEntries, currentUserId, owner.id]); + + return ( +
+ + + + + + + + + + + + + {sortedEntries.map((entry) => ( + + ))} + +
Membre + Performance + MoralFlux + Création de valeur + + Notes +
+
+ ); +} diff --git a/src/components/weather/WeatherCard.tsx b/src/components/weather/WeatherCard.tsx new file mode 100644 index 0000000..da385a5 --- /dev/null +++ b/src/components/weather/WeatherCard.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { createOrUpdateWeatherEntry } from '@/actions/weather'; +import { Avatar } from '@/components/ui/Avatar'; +import { Textarea } from '@/components/ui/Textarea'; + +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' }, +]; + +interface WeatherEntry { + id: string; + userId: string; + performanceEmoji: string | null; + moralEmoji: string | null; + fluxEmoji: string | null; + valueCreationEmoji: string | null; + notes: string | null; + user: { + id: string; + name: string | null; + email: string; + }; +} + +interface WeatherCardProps { + sessionId: string; + currentUserId: string; + entry: WeatherEntry; + canEdit: boolean; +} + +export function WeatherCard({ sessionId, currentUserId, entry, canEdit }: WeatherCardProps) { + const [isPending, startTransition] = useTransition(); + const [notes, setNotes] = useState(entry.notes || ''); + const [performanceEmoji, setPerformanceEmoji] = useState(entry.performanceEmoji || null); + const [moralEmoji, setMoralEmoji] = useState(entry.moralEmoji || null); + const [fluxEmoji, setFluxEmoji] = useState(entry.fluxEmoji || null); + const [valueCreationEmoji, setValueCreationEmoji] = useState(entry.valueCreationEmoji || null); + + const isCurrentUser = entry.userId === currentUserId; + const canEditThis = canEdit && isCurrentUser; + + function handleEmojiChange(axis: 'performance' | 'moral' | 'flux' | 'valueCreation', emoji: string | null) { + if (!canEditThis) return; + + // Calculate new values + const newPerformanceEmoji = axis === 'performance' ? emoji : performanceEmoji; + const newMoralEmoji = axis === 'moral' ? emoji : moralEmoji; + const newFluxEmoji = axis === 'flux' ? emoji : fluxEmoji; + const newValueCreationEmoji = axis === 'valueCreation' ? emoji : valueCreationEmoji; + + // Update local state immediately + if (axis === 'performance') { + setPerformanceEmoji(emoji); + } else if (axis === 'moral') { + setMoralEmoji(emoji); + } else if (axis === 'flux') { + setFluxEmoji(emoji); + } else if (axis === 'valueCreation') { + setValueCreationEmoji(emoji); + } + + // Save to server with new values + startTransition(async () => { + await createOrUpdateWeatherEntry(sessionId, { + performanceEmoji: newPerformanceEmoji, + moralEmoji: newMoralEmoji, + fluxEmoji: newFluxEmoji, + valueCreationEmoji: newValueCreationEmoji, + notes, + }); + }); + } + + function handleNotesChange(newNotes: string) { + if (!canEditThis) return; + setNotes(newNotes); + } + + function handleNotesBlur() { + if (!canEditThis) return; + startTransition(async () => { + await createOrUpdateWeatherEntry(sessionId, { + performanceEmoji, + moralEmoji, + fluxEmoji, + valueCreationEmoji, + notes, + }); + }); + } + + // For current user without entry, we need to get user info from somewhere + // For now, we'll use a placeholder - in real app, you'd pass user info as prop + const user = entry.user; + + return ( + + {/* User column */} + +
+ + + {user.name || user.email || 'Vous'} + +
+ + + {/* Performance */} + + {canEditThis ? ( +
+ +
+ + + +
+
+ ) : ( +
{performanceEmoji || '-'}
+ )} + + + {/* Moral */} + + {canEditThis ? ( +
+ +
+ + + +
+
+ ) : ( +
{moralEmoji || '-'}
+ )} + + + {/* Flux */} + + {canEditThis ? ( +
+ +
+ + + +
+
+ ) : ( +
{fluxEmoji || '-'}
+ )} + + + {/* Création de valeur */} + + {canEditThis ? ( +
+ +
+ + + +
+
+ ) : ( +
{valueCreationEmoji || '-'}
+ )} + + + {/* Notes */} + + {canEditThis ? ( +