Compare commits

...

7 Commits

Author SHA1 Message Date
Julien Froidefond
39910f559e feat: improve SSE broadcasting for weather sessions with enhanced error handling and connection management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m49s
2026-02-04 13:18:10 +01:00
Julien Froidefond
057732f00e feat: enhance real-time weather session updates by broadcasting user information and syncing local state in WeatherCard component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m14s
2026-02-04 11:05:33 +01:00
Julien Froidefond
e8ffccd286 refactor: streamline date and title handling in NewWeatherPage and NewWeeklyCheckInPage components for improved user experience
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-04 11:02:52 +01:00
Julien Froidefond
ef0772f894 feat: enhance user experience by adding notifications for OKR updates and improving session timeout handling for better usability
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m21s
2026-02-04 10:50:38 +01:00
Julien Froidefond
163caa398c feat: implement Weather Workshop feature with models, UI components, and session management for enhanced team visibility and personal well-being tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m16s
2026-02-03 18:08:06 +01:00
Julien Froidefond
3a2eb83197 feat: add comparePeriods utility for sorting OKR periods and refactor ObjectivesPage to utilize it for improved period sorting
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8m28s
2026-01-28 13:58:01 +01:00
Julien Froidefond
e848e85b63 feat: implement period filtering in OKRsList component with toggle for viewing all OKRs or current quarter's OKRs
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-01-28 13:54:10 +01:00
24 changed files with 2559 additions and 52 deletions

View File

@@ -0,0 +1,76 @@
-- CreateTable
CREATE TABLE "WeatherSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeatherSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeatherEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"performanceEmoji" TEXT,
"moralEmoji" TEXT,
"fluxEmoji" TEXT,
"valueCreationEmoji" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WeatherEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeatherEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeatherSessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WeatherSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeatherSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WeatherSessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WeatherSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeatherSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WeatherSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "WeatherSession_userId_idx" ON "WeatherSession"("userId");
-- CreateIndex
CREATE INDEX "WeatherSession_date_idx" ON "WeatherSession"("date");
-- CreateIndex
CREATE UNIQUE INDEX "WeatherEntry_sessionId_userId_key" ON "WeatherEntry"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "WeatherEntry_sessionId_idx" ON "WeatherEntry"("sessionId");
-- CreateIndex
CREATE INDEX "WeatherEntry_userId_idx" ON "WeatherEntry"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "WeatherSessionShare_sessionId_userId_key" ON "WeatherSessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "WeatherSessionShare_sessionId_idx" ON "WeatherSessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "WeatherSessionShare_userId_idx" ON "WeatherSessionShare"("userId");
-- CreateIndex
CREATE INDEX "WeatherSessionEvent_sessionId_createdAt_idx" ON "WeatherSessionEvent"("sessionId", "createdAt");

View File

@@ -29,6 +29,11 @@ model User {
weeklyCheckInSessions WeeklyCheckInSession[] weeklyCheckInSessions WeeklyCheckInSession[]
sharedWeeklyCheckInSessions WCISessionShare[] sharedWeeklyCheckInSessions WCISessionShare[]
weeklyCheckInSessionEvents WCISessionEvent[] weeklyCheckInSessionEvents WCISessionEvent[]
// Weather Workshop relations
weatherSessions WeatherSession[]
sharedWeatherSessions WeatherSessionShare[]
weatherSessionEvents WeatherSessionEvent[]
weatherEntries WeatherEntry[]
// Teams & OKRs relations // Teams & OKRs relations
createdTeams Team[] createdTeams Team[]
teamMembers TeamMember[] teamMembers TeamMember[]
@@ -454,3 +459,69 @@ model WCISessionEvent {
@@index([sessionId, createdAt]) @@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])
}

276
src/actions/weather.ts Normal file
View File

@@ -0,0 +1,276 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as weatherService from '@/services/weather';
import { getUserById } from '@/services/auth';
import { broadcastToWeatherSession } from '@/app/api/weather/[id]/subscribe/route';
// ============================================
// 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);
// Get user info for broadcast
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
// Emit event for real-time sync
const event = await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
// Broadcast immediately via SSE
broadcastToWeatherSession(sessionId, {
type: 'SESSION_UPDATED',
payload: data,
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
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);
// Get user info for broadcast
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
// Emit event for real-time sync
const eventType = entry.createdAt.getTime() === entry.updatedAt.getTime() ? 'ENTRY_CREATED' : 'ENTRY_UPDATED';
const event = await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
eventType,
{
entryId: entry.id,
userId: entry.userId,
...data,
}
);
// Broadcast immediately via SSE
broadcastToWeatherSession(sessionId, {
type: eventType,
payload: {
entryId: entry.id,
userId: entry.userId,
...data,
},
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
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);
// Get user info for broadcast
const user = await getUserById(authSession.user.id);
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' };
}
// Emit event for real-time sync
const event = await weatherService.createWeatherSessionEvent(
sessionId,
authSession.user.id,
'ENTRY_DELETED',
{ userId: authSession.user.id }
);
// Broadcast immediately via SSE
broadcastToWeatherSession(sessionId, {
type: 'ENTRY_DELETED',
payload: { userId: authSession.user.id },
userId: authSession.user.id,
user: { id: user.id, name: user.name, email: user.email },
timestamp: event.createdAt,
});
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' };
}
}

View File

@@ -0,0 +1,135 @@
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<string, Set<ReadableStreamDefaultController>>();
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) {
try {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections || sessionConnections.size === 0) {
// No active connections, event will be picked up by polling
console.log(`[SSE Broadcast] No connections for session ${sessionId}, will be picked up by polling`);
return;
}
console.log(`[SSE Broadcast] Broadcasting to ${sessionConnections.size} connections for session ${sessionId}`);
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
let sentCount = 0;
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
sentCount++;
} catch (error) {
// Connection might be closed, remove it
console.log(`[SSE Broadcast] Failed to send, removing connection:`, error);
sessionConnections.delete(controller);
}
}
console.log(`[SSE Broadcast] Sent to ${sentCount} connections`);
// Clean up empty sets
if (sessionConnections.size === 0) {
connections.delete(sessionId);
}
} catch (error) {
console.error('[SSE Broadcast] Error broadcasting:', error);
}
}

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { getUserOKRs } from '@/services/okrs'; import { getUserOKRs } from '@/services/okrs';
import { Card } from '@/components/ui'; import { Card } from '@/components/ui';
import { ObjectivesList } from '@/components/okrs/ObjectivesList'; import { ObjectivesList } from '@/components/okrs/ObjectivesList';
import { comparePeriods } from '@/lib/okr-utils';
export default async function ObjectivesPage() { export default async function ObjectivesPage() {
const session = await auth(); const session = await auth();
@@ -27,16 +28,7 @@ export default async function ObjectivesPage() {
{} as Record<string, typeof okrs> {} as Record<string, typeof okrs>
); );
const periods = Object.keys(okrsByPeriod).sort((a, b) => { const periods = Object.keys(okrsByPeriod).sort(comparePeriods);
// Sort periods: extract year and quarter/period
const aMatch = a.match(/(\d{4})/);
const bMatch = b.match(/(\d{4})/);
if (aMatch && bMatch) {
const yearDiff = parseInt(bMatch[1]) - parseInt(aMatch[1]);
if (yearDiff !== 0) return yearDiff;
}
return b.localeCompare(a);
});
return ( return (
<main className="mx-auto max-w-7xl px-4 py-8"> <main className="mx-auto max-w-7xl px-4 py-8">

View File

@@ -84,6 +84,22 @@ export default function Home() {
accentColor="#10b981" accentColor="#10b981"
newHref="/weekly-checkin/new" newHref="/weekly-checkin/new"
/> />
{/* Weather Workshop Card */}
<WorkshopCard
href="/sessions?tab=weather"
icon="🌤️"
title="Météo"
tagline="Votre état en un coup d'œil"
description="Créez votre météo personnelle sur 4 axes clés (Performance, Moral, Flux, Création de valeur) et partagez-la avec votre équipe pour une meilleure visibilité de votre état."
features={[
'4 axes : Performance, Moral, Flux, Création de valeur',
'Emojis météo pour exprimer votre état visuellement',
'Notes globales pour détailler votre ressenti',
]}
accentColor="#3b82f6"
newHref="/weather/new"
/>
</div> </div>
</section> </section>
@@ -459,6 +475,94 @@ export default function Home() {
</div> </div>
</section> </section>
{/* Weather Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">🌤</span>
<div>
<h2 className="text-3xl font-bold text-foreground">Météo</h2>
<p className="text-blue-500 font-medium">Votre état en un coup d&apos;œil</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi créer une météo personnelle ?
</h3>
<p className="text-muted mb-4">
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.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Exprimer rapidement votre état avec des emojis météo intuitifs
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Partager votre météo avec votre équipe pour une meilleure visibilité
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Créer un espace de dialogue ouvert sur votre performance et votre moral
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500"></span>
Suivre l&apos;évolution de votre état dans le temps
</li>
</ul>
</div>
{/* The 4 axes */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">📋</span>
Les 4 axes de la météo
</h3>
<div className="space-y-3">
<CategoryPill icon="☀️" name="Performance" color="#f59e0b" description="Votre performance personnelle et l'atteinte de vos objectifs" />
<CategoryPill icon="😊" name="Moral" color="#22c55e" description="Votre moral actuel et votre ressenti" />
<CategoryPill icon="🌊" name="Flux" color="#3b82f6" description="Votre flux de travail personnel et les blocages éventuels" />
<CategoryPill icon="💎" name="Création de valeur" color="#8b5cf6" description="Votre création de valeur et votre apport" />
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer votre météo"
description="Créez une nouvelle météo personnelle avec un titre et une date"
/>
<StepCard
number={2}
title="Choisir vos emojis"
description="Pour chaque axe, sélectionnez un emoji météo qui reflète votre état"
/>
<StepCard
number={3}
title="Ajouter des notes"
description="Complétez avec des notes globales pour détailler votre ressenti"
/>
<StepCard
number={4}
title="Partager avec l'équipe"
description="Partagez votre météo avec votre équipe ou une équipe entière pour qu'ils puissent voir votre état"
/>
</div>
</div>
</div>
</section>
{/* OKRs Deep Dive Section */} {/* OKRs Deep Dive Section */}
<section className="mb-16"> <section className="mb-16">
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-8">

View File

@@ -16,8 +16,9 @@ import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators'; import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review'; import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review';
import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin'; 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[] = [ const VALID_TABS: WorkshopType[] = [
'all', 'all',
@@ -25,6 +26,7 @@ const VALID_TABS: WorkshopType[] = [
'motivators', 'motivators',
'year-review', 'year-review',
'weekly-checkin', 'weekly-checkin',
'weather',
'byPerson', 'byPerson',
]; ];
@@ -107,13 +109,27 @@ interface WeeklyCheckInSession {
workshopType: 'weekly-checkin'; 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 { interface WorkshopTabsProps {
swotSessions: SwotSession[]; swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[]; motivatorSessions: MotivatorSession[];
yearReviewSessions: YearReviewSession[]; yearReviewSessions: YearReviewSession[];
weeklyCheckInSessions: WeeklyCheckInSession[]; weeklyCheckInSessions: WeeklyCheckInSession[];
weatherSessions: WeatherSession[];
} }
// Helper to get resolved collaborator from any session // Helper to get resolved collaborator from any session
@@ -124,6 +140,17 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return (session as YearReviewSession).resolvedParticipant; return (session as YearReviewSession).resolvedParticipant;
} else if (session.workshopType === 'weekly-checkin') { } else if (session.workshopType === 'weekly-checkin') {
return (session as WeeklyCheckInSession).resolvedParticipant; 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 { } else {
return (session as MotivatorSession).resolvedParticipant; return (session as MotivatorSession).resolvedParticipant;
} }
@@ -168,6 +195,7 @@ export function WorkshopTabs({
motivatorSessions, motivatorSessions,
yearReviewSessions, yearReviewSessions,
weeklyCheckInSessions, weeklyCheckInSessions,
weatherSessions,
}: WorkshopTabsProps) { }: WorkshopTabsProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -193,6 +221,7 @@ export function WorkshopTabs({
...motivatorSessions, ...motivatorSessions,
...yearReviewSessions, ...yearReviewSessions,
...weeklyCheckInSessions, ...weeklyCheckInSessions,
...weatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
// Filter based on active tab (for non-byPerson tabs) // Filter based on active tab (for non-byPerson tabs)
@@ -205,7 +234,9 @@ export function WorkshopTabs({
? motivatorSessions ? motivatorSessions
: activeTab === 'year-review' : activeTab === 'year-review'
? yearReviewSessions ? yearReviewSessions
: weeklyCheckInSessions; : activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: weatherSessions;
// Separate by ownership // Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner); const ownedSessions = filteredSessions.filter((s) => s.isOwner);
@@ -263,6 +294,13 @@ export function WorkshopTabs({
label="Weekly Check-in" label="Weekly Check-in"
count={weeklyCheckInSessions.length} count={weeklyCheckInSessions.length}
/> />
<TabButton
active={activeTab === 'weather'}
onClick={() => setActiveTab('weather')}
icon="🌤️"
label="Météo"
count={weatherSessions.length}
/>
</div> </div>
{/* Sessions */} {/* Sessions */}
@@ -375,34 +413,43 @@ function SessionCard({ session }: { session: AnySession }) {
? (session as SwotSession).collaborator ? (session as SwotSession).collaborator
: session.workshopType === 'year-review' : session.workshopType === 'year-review'
? (session as YearReviewSession).participant ? (session as YearReviewSession).participant
: (session as MotivatorSession).participant : session.workshopType === 'weather'
? ''
: (session as MotivatorSession).participant
); );
const isSwot = session.workshopType === 'swot'; const isSwot = session.workshopType === 'swot';
const isYearReview = session.workshopType === 'year-review'; const isYearReview = session.workshopType === 'year-review';
const isWeeklyCheckIn = session.workshopType === 'weekly-checkin'; const isWeeklyCheckIn = session.workshopType === 'weekly-checkin';
const isWeather = session.workshopType === 'weather';
const href = isSwot const href = isSwot
? `/sessions/${session.id}` ? `/sessions/${session.id}`
: isYearReview : isYearReview
? `/year-review/${session.id}` ? `/year-review/${session.id}`
: isWeeklyCheckIn : isWeeklyCheckIn
? `/weekly-checkin/${session.id}` ? `/weekly-checkin/${session.id}`
: `/motivators/${session.id}`; : isWeather
const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : '🎯'; ? `/weather/${session.id}`
: `/motivators/${session.id}`;
const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : isWeather ? '🌤️' : '🎯';
const participant = isSwot const participant = isSwot
? (session as SwotSession).collaborator ? (session as SwotSession).collaborator
: isYearReview : isYearReview
? (session as YearReviewSession).participant ? (session as YearReviewSession).participant
: isWeeklyCheckIn : isWeeklyCheckIn
? (session as WeeklyCheckInSession).participant ? (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 const accentColor = isSwot
? '#06b6d4' ? '#06b6d4'
: isYearReview : isYearReview
? '#f59e0b' ? '#f59e0b'
: isWeeklyCheckIn : isWeeklyCheckIn
? '#10b981' ? '#10b981'
: '#8b5cf6'; : isWeather
? '#3b82f6'
: '#8b5cf6';
const handleDelete = () => { const handleDelete = () => {
startTransition(async () => { startTransition(async () => {
@@ -412,7 +459,9 @@ function SessionCard({ session }: { session: AnySession }) {
? await deleteYearReviewSession(session.id) ? await deleteYearReviewSession(session.id)
: isWeeklyCheckIn : isWeeklyCheckIn
? await deleteWeeklyCheckInSession(session.id) ? await deleteWeeklyCheckInSession(session.id)
: await deleteMotivatorSession(session.id); : isWeather
? await deleteWeatherSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) { if (result.success) {
setShowDeleteModal(false); setShowDeleteModal(false);
@@ -436,10 +485,12 @@ function SessionCard({ session }: { session: AnySession }) {
title: editTitle, title: editTitle,
participant: editParticipant, participant: editParticipant,
}) })
: await updateMotivatorSession(session.id, { : isWeather
title: editTitle, ? await updateWeatherSession(session.id, { title: editTitle })
participant: editParticipant, : await updateMotivatorSession(session.id, {
}); title: editTitle,
participant: editParticipant,
});
if (result.success) { if (result.success) {
setShowEditModal(false); setShowEditModal(false);
@@ -456,7 +507,7 @@ function SessionCard({ session }: { session: AnySession }) {
setShowEditModal(true); setShowEditModal(true);
}; };
const editParticipantLabel = isSwot ? 'Collaborateur' : 'Participant'; const editParticipantLabel = isSwot ? 'Collaborateur' : isWeather ? '' : 'Participant';
return ( return (
<> <>
@@ -524,6 +575,17 @@ function SessionCard({ session }: { session: AnySession }) {
})} })}
</span> </span>
</> </>
) : isWeather ? (
<>
<span>{(session as WeatherSession)._count.entries} membres</span>
<span>·</span>
<span>
{new Date((session as WeatherSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : ( ) : (
<span>{(session as MotivatorSession)._count.cards}/10</span> <span>{(session as MotivatorSession)._count.cards}/10</span>
)} )}
@@ -640,13 +702,15 @@ function SessionCard({ session }: { session: AnySession }) {
> >
{editParticipantLabel} {editParticipantLabel}
</label> </label>
<Input {!isWeather && (
id="edit-participant" <Input
value={editParticipant} id="edit-participant"
onChange={(e) => setEditParticipant(e.target.value)} value={editParticipant}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'} onChange={(e) => setEditParticipant(e.target.value)}
required placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
/> required
/>
)}
</div> </div>
<ModalFooter> <ModalFooter>
<Button <Button
@@ -659,7 +723,7 @@ function SessionCard({ session }: { session: AnySession }) {
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isPending || !editTitle.trim() || !editParticipant.trim()} disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
> >
{isPending ? 'Enregistrement...' : 'Enregistrer'} {isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button> </Button>

View File

@@ -5,6 +5,7 @@ import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators'; import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { getYearReviewSessionsByUserId } from '@/services/year-review'; import { getYearReviewSessionsByUserId } from '@/services/year-review';
import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin'; import { getWeeklyCheckInSessionsByUserId } from '@/services/weekly-checkin';
import { getWeatherSessionsByUserId } from '@/services/weather';
import { Card, Button } from '@/components/ui'; import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs'; import { WorkshopTabs } from './WorkshopTabs';
@@ -34,13 +35,14 @@ export default async function SessionsPage() {
return null; return null;
} }
// Fetch SWOT, Moving Motivators, Year Review, and Weekly Check-in sessions // Fetch SWOT, Moving Motivators, Year Review, Weekly Check-in, and Weather sessions
const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions] = const [swotSessions, motivatorSessions, yearReviewSessions, weeklyCheckInSessions, weatherSessions] =
await Promise.all([ await Promise.all([
getSessionsByUserId(session.user.id), getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id), getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id), getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id), getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id),
]); ]);
// Add type to each session for unified display // Add type to each session for unified display
@@ -64,12 +66,18 @@ export default async function SessionsPage() {
workshopType: 'weekly-checkin' as const, workshopType: 'weekly-checkin' as const,
})); }));
const allWeatherSessions = weatherSessions.map((s) => ({
...s,
workshopType: 'weather' as const,
}));
// Combine and sort by updatedAt // Combine and sort by updatedAt
const allSessions = [ const allSessions = [
...allSwotSessions, ...allSwotSessions,
...allMotivatorSessions, ...allMotivatorSessions,
...allYearReviewSessions, ...allYearReviewSessions,
...allWeeklyCheckInSessions, ...allWeeklyCheckInSessions,
...allWeatherSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0; const hasNoSessions = allSessions.length === 0;
@@ -102,11 +110,17 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/weekly-checkin/new"> <Link href="/weekly-checkin/new">
<Button> <Button variant="outline">
<span>📝</span> <span>📝</span>
Nouveau Check-in Nouveau Check-in
</Button> </Button>
</Link> </Link>
<Link href="/weather/new">
<Button>
<span>🌤</span>
Nouvelle Météo
</Button>
</Link>
</div> </div>
</div> </div>
@@ -142,11 +156,17 @@ export default async function SessionsPage() {
</Button> </Button>
</Link> </Link>
<Link href="/weekly-checkin/new"> <Link href="/weekly-checkin/new">
<Button> <Button variant="outline">
<span>📝</span> <span>📝</span>
Créer un Check-in Créer un Check-in
</Button> </Button>
</Link> </Link>
<Link href="/weather/new">
<Button>
<span>🌤</span>
Créer une Météo
</Button>
</Link>
</div> </div>
</Card> </Card>
) : ( ) : (
@@ -156,6 +176,7 @@ export default async function SessionsPage() {
motivatorSessions={allMotivatorSessions} motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions} yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions} weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions}
/> />
</Suspense> </Suspense>
)} )}

View File

@@ -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 (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions?tab=weather" className="hover:text-foreground">
Météo
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableWeatherTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.entries.length} membres</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Info sur les catégories */}
<WeatherInfoPanel />
{/* Live Wrapper + Board */}
<WeatherLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<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={{
id: session.user.id,
name: session.user.name ?? null,
email: session.user.email ?? '',
}}
canEdit={session.canEdit}
/>
</WeatherLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } 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<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0])));
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const date = selectedDate ? new Date(selectedDate) : 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}`);
}
function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
const newDate = e.target.value;
setSelectedDate(newDate);
// Only update title if user hasn't manually modified it
if (!isTitleManuallyEdited) {
setTitle(getWeekYearLabel(new Date(newDate)));
}
}
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
setIsTitleManuallyEdited(true);
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🌤</span>
Nouvelle Météo
</CardTitle>
<CardDescription>
Créez une météo personnelle pour faire le point sur 4 axes clés et partagez-la avec votre équipe
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la météo"
name="title"
placeholder="Ex: Météo S05 - 2026"
value={title}
onChange={handleTitleChange}
required
/>
<div>
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
Date de la météo
</label>
<input
id="date"
name="date"
type="date"
value={selectedDate}
onChange={handleDateChange}
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"
/>
</div>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>
<strong>Performance</strong> : Comment évaluez-vous votre performance personnelle ?
</li>
<li>
<strong>Moral</strong> : Quel est votre moral actuel ?
</li>
<li>
<strong>Flux</strong> : Comment se passe votre flux de travail personnel ?
</li>
<li>
<strong>Création de valeur</strong> : Comment évaluez-vous votre création de valeur ?
</li>
</ol>
<p className="text-sm text-muted mt-2">
💡 <strong>Astuce</strong> : Partagez votre météo avec votre équipe pour qu&apos;ils puissent voir votre état. Chaque membre peut créer sa propre météo et la partager !
</p>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer la météo
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -12,11 +12,15 @@ import {
Input, Input,
} from '@/components/ui'; } from '@/components/ui';
import { createWeeklyCheckInSession } from '@/actions/weekly-checkin'; import { createWeeklyCheckInSession } from '@/actions/weekly-checkin';
import { getWeekYearLabel } from '@/lib/date-utils';
export default function NewWeeklyCheckInPage() { export default function NewWeeklyCheckInPage() {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [title, setTitle] = useState(() => getWeekYearLabel(new Date(new Date().toISOString().split('T')[0])));
const [isTitleManuallyEdited, setIsTitleManuallyEdited] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
@@ -24,10 +28,8 @@ export default function NewWeeklyCheckInPage() {
setLoading(true); setLoading(true);
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const participant = formData.get('participant') as string; const participant = formData.get('participant') as string;
const dateStr = formData.get('date') as string; const date = selectedDate ? new Date(selectedDate) : undefined;
const date = dateStr ? new Date(dateStr) : undefined;
if (!title || !participant) { if (!title || !participant) {
setError('Veuillez remplir tous les champs'); setError('Veuillez remplir tous les champs');
@@ -46,8 +48,19 @@ export default function NewWeeklyCheckInPage() {
router.push(`/weekly-checkin/${result.data?.id}`); router.push(`/weekly-checkin/${result.data?.id}`);
} }
// Default date to today function handleDateChange(e: React.ChangeEvent<HTMLInputElement>) {
const today = new Date().toISOString().split('T')[0]; const newDate = e.target.value;
setSelectedDate(newDate);
// Only update title if user hasn't manually modified it
if (!isTitleManuallyEdited) {
setTitle(getWeekYearLabel(new Date(newDate)));
}
}
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
setIsTitleManuallyEdited(true);
}
return ( return (
<main className="mx-auto max-w-2xl px-4 py-8"> <main className="mx-auto max-w-2xl px-4 py-8">
@@ -75,6 +88,8 @@ export default function NewWeeklyCheckInPage() {
label="Titre du check-in" label="Titre du check-in"
name="title" name="title"
placeholder="Ex: Check-in semaine du 15 janvier" placeholder="Ex: Check-in semaine du 15 janvier"
value={title}
onChange={handleTitleChange}
required required
/> />
@@ -93,7 +108,8 @@ export default function NewWeeklyCheckInPage() {
id="date" id="date"
name="date" name="date"
type="date" type="date"
defaultValue={today} value={selectedDate}
onChange={handleDateChange}
required 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" 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"
/> />

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { OKRCard } from './OKRCard'; import { OKRCard } from './OKRCard';
import { Card, ToggleGroup } from '@/components/ui'; import { Card, ToggleGroup, Button } from '@/components/ui';
import { getGravatarUrl } from '@/lib/gravatar'; import { getGravatarUrl } from '@/lib/gravatar';
import { getCurrentQuarterPeriod, isCurrentQuarterPeriod } from '@/lib/okr-utils';
import type { OKR } from '@/lib/types'; import type { OKR } from '@/lib/types';
type ViewMode = 'grid' | 'grouped'; type ViewMode = 'grid' | 'grouped';
@@ -33,6 +34,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
} }
return 'detailed'; return 'detailed';
}); });
const [showAllPeriods, setShowAllPeriods] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -40,8 +42,21 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
} }
}, [cardViewMode]); }, [cardViewMode]);
const currentQuarterPeriod = getCurrentQuarterPeriod();
// Filter OKRs based on period filter
const filteredOKRsData = useMemo(() => {
if (showAllPeriods) {
return okrsData;
}
return okrsData.map((tm) => ({
...tm,
okrs: tm.okrs.filter((okr) => isCurrentQuarterPeriod(okr.period)),
}));
}, [okrsData, showAllPeriods]);
// Flatten OKRs for grid view // Flatten OKRs for grid view
const allOKRs = okrsData.flatMap((tm) => const allOKRs = filteredOKRsData.flatMap((tm) =>
tm.okrs.map((okr) => ({ tm.okrs.map((okr) => ({
...okr, ...okr,
teamMember: { teamMember: {
@@ -52,11 +67,37 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
if (allOKRs.length === 0) { if (allOKRs.length === 0) {
return ( return (
<Card className="p-12 text-center"> <div>
<div className="text-5xl mb-4">🎯</div> {/* View Toggle */}
<h3 className="text-xl font-semibold text-foreground mb-2">Aucun OKR défini</h3> <div className="mb-6 flex items-center justify-between gap-4">
<p className="text-muted">Aucun OKR n&apos;a encore é défini pour cette équipe</p> <div className="flex items-center gap-3">
</Card> <h2 className="text-2xl font-bold text-foreground">OKRs</h2>
{!showAllPeriods && (
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm"
>
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
</Button>
</div>
</div>
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
{showAllPeriods ? 'Aucun OKR défini' : `Aucun OKR pour ${currentQuarterPeriod}`}
</h3>
<p className="text-muted">
{showAllPeriods
? "Aucun OKR n'a encore été défini pour cette équipe"
: `Aucun OKR n'a été défini pour le trimestre ${currentQuarterPeriod}. Cliquez sur "Afficher tous les OKR" pour voir les OKR d'autres périodes.`}
</p>
</Card>
</div>
); );
} }
@@ -64,8 +105,20 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
<div> <div>
{/* View Toggle */} {/* View Toggle */}
<div className="mb-6 flex items-center justify-between gap-4"> <div className="mb-6 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-foreground">OKRs</h2>
{!showAllPeriods && (
<span className="text-sm text-muted">({currentQuarterPeriod})</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => setShowAllPeriods(!showAllPeriods)}
className="text-sm"
>
{showAllPeriods ? `Afficher ${currentQuarterPeriod} uniquement` : 'Afficher tous les OKR'}
</Button>
<ToggleGroup <ToggleGroup
value={cardViewMode} value={cardViewMode}
onChange={setCardViewMode} onChange={setCardViewMode}
@@ -140,7 +193,7 @@ export function OKRsList({ okrsData, teamId, isAdmin = false }: OKRsListProps) {
{/* Grouped View */} {/* Grouped View */}
{viewMode === 'grouped' ? ( {viewMode === 'grouped' ? (
<div className="space-y-8"> <div className="space-y-8">
{okrsData {filteredOKRsData
.filter((tm) => tm.okrs.length > 0) .filter((tm) => tm.okrs.length > 0)
.map((teamMember) => ( .map((teamMember) => (
<div key={teamMember.user.id}> <div key={teamMember.user.id}>

View File

@@ -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 (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
isOwner={isOwner}
onUpdate={async (id, title) => {
const result = await updateWeatherSession(id, { title });
return result;
}}
/>
);
}

View File

@@ -8,6 +8,7 @@ export { EditableSessionTitle } from './EditableSessionTitle';
export { EditableMotivatorTitle } from './EditableMotivatorTitle'; export { EditableMotivatorTitle } from './EditableMotivatorTitle';
export { EditableYearReviewTitle } from './EditableYearReviewTitle'; export { EditableYearReviewTitle } from './EditableYearReviewTitle';
export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle'; export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle';
export { EditableWeatherTitle } from './EditableWeatherTitle';
export { Input } from './Input'; export { Input } from './Input';
export { Modal, ModalFooter } from './Modal'; export { Modal, ModalFooter } from './Modal';
export { Select } from './Select'; export { Select } from './Select';

View File

@@ -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<string, { id: string; name: string | null; email: string }>();
// 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<string, WeatherEntry>();
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 (
<div className="overflow-x-auto rounded-lg border border-border bg-card">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-card-column">
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Membre</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Performance
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Moral</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">Flux</th>
<th className="px-4 py-3 text-center text-sm font-medium text-foreground">
Création de valeur
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[300px]">
Notes
</th>
</tr>
</thead>
<tbody>
{sortedEntries.map((entry) => (
<WeatherCard
key={entry.userId}
sessionId={sessionId}
currentUserId={currentUserId}
entry={entry}
canEdit={canEdit}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import { useState, useTransition, useEffect } 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;
// 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);
setFluxEmoji(entry.fluxEmoji || null);
setValueCreationEmoji(entry.valueCreationEmoji || null);
}, [entry.notes, entry.performanceEmoji, entry.moralEmoji, entry.fluxEmoji, entry.valueCreationEmoji]);
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 (
<tr className={`border-b border-border ${isPending ? 'opacity-50' : ''}`}>
{/* User column */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Avatar email={user.email} name={user.name} size={32} />
<span className="text-sm font-medium text-foreground">
{user.name || user.email || 'Vous'}
</span>
</div>
</td>
{/* Performance */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={performanceEmoji || ''}
onChange={(e) => handleEmojiChange('performance', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{performanceEmoji || '-'}</div>
)}
</td>
{/* Moral */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={moralEmoji || ''}
onChange={(e) => handleEmojiChange('moral', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{moralEmoji || '-'}</div>
)}
</td>
{/* Flux */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={fluxEmoji || ''}
onChange={(e) => handleEmojiChange('flux', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{fluxEmoji || '-'}</div>
)}
</td>
{/* Création de valeur */}
<td className="w-24 px-2 py-3">
{canEditThis ? (
<div className="relative mx-auto w-fit">
<select
value={valueCreationEmoji || ''}
onChange={(e) => handleEmojiChange('valueCreation', e.target.value || null)}
className="w-16 appearance-none rounded-lg border border-border bg-card px-2 py-2.5 pr-8 text-center text-lg text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{WEATHER_EMOJIS.map(({ emoji }) => (
<option key={emoji || 'none'} value={emoji}>
{emoji}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<svg
className="h-3 w-3 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
) : (
<div className="text-2xl text-center">{valueCreationEmoji || '-'}</div>
)}
</td>
{/* Notes */}
<td className="px-4 py-3 min-w-[400px]">
{canEditThis ? (
<Textarea
value={notes}
onChange={(e) => handleNotesChange(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Notes globales..."
className="min-h-[120px] w-full resize-y"
rows={5}
/>
) : (
<div className="text-sm text-foreground whitespace-pre-wrap min-h-[120px]">
{notes || '-'}
</div>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
export function WeatherInfoPanel() {
const [isOpen, setIsOpen] = useState(false);
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">Les 4 axes de la météo personnelle</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-3">
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-xs font-medium text-foreground mb-0.5"> Performance</p>
<p className="text-xs text-muted leading-relaxed">
Votre performance personnelle et l&apos;atteinte de vos objectifs
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">😊 Moral</p>
<p className="text-xs text-muted leading-relaxed">
Votre moral actuel et votre ressenti
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">🌊 Flux</p>
<p className="text-xs text-muted leading-relaxed">
Votre flux de travail personnel et les blocages éventuels
</p>
</div>
<div>
<p className="text-xs font-medium text-foreground mb-0.5">💎 Création de valeur</p>
<p className="text-xs text-muted leading-relaxed">
Votre création de valeur et votre apport
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { WeatherShareModal } from './WeatherShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: Team[];
children: React.ReactNode;
}
export function WeatherLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: WeatherLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeatherLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useWeatherLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<WeatherShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
/>
</>
);
}

View File

@@ -0,0 +1,307 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
interface WeatherShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
userTeams?: Team[];
}
export function WeatherShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
userTeams = [],
}: WeatherShareModalProps) {
const [shareType, setShareType] = useState<'user' | 'team'>('user');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
let result;
if (shareType === 'team') {
result = await shareWeatherSessionToTeam(sessionId, teamId, role);
} else {
result = await shareWeatherSession(sessionId, email, role);
}
if (result.success) {
setEmail('');
setTeamId('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeWeatherShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la météo">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Météo personnelle</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
{/* Share type selector */}
<div className="flex gap-2 border-b border-border pb-3">
<button
type="button"
onClick={() => {
setShareType('user');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👤 Utilisateur
</button>
<button
type="button"
onClick={() => {
setShareType('team');
setEmail('');
setTeamId('');
}}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
shareType === 'team'
? 'bg-primary text-primary-foreground'
: 'bg-card-hover text-muted hover:text-foreground'
}`}
>
👥 Équipe
</button>
</div>
{/* User share */}
{shareType === 'user' && (
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
)}
{/* Team share */}
{shareType === 'team' && (
<div className="space-y-2">
{userTeams.length === 0 ? (
<p className="text-sm text-muted">
Vous n&apos;êtes membre d&apos;aucune équipe. Créez une équipe depuis la page{' '}
<Link href="/teams" className="text-primary hover:underline">
Équipes
</Link>
.
</p>
) : (
<>
<div className="relative">
<select
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
required
>
<option value="">Sélectionner une équipe</option>
{userTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name} {team.userRole === 'ADMIN' && '(Admin)'}
</option>
))}
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div className="relative">
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="w-full appearance-none rounded-lg border border-border bg-card px-3 py-2.5 pr-10 text-sm text-foreground transition-colors hover:bg-card-hover focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<svg
className="h-4 w-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</>
)}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
type="submit"
disabled={isPending || (shareType === 'user' && !email) || (shareType === 'team' && !teamId)}
className="w-full"
>
{isPending ? 'Partage...' : shareType === 'team' ? "Partager à l'équipe" : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
export { WeatherBoard } from './WeatherBoard';
export { WeatherCard } from './WeatherCard';
export { WeatherLiveWrapper } from './WeatherLiveWrapper';
export { WeatherShareModal } from './WeatherShareModal';
export { WeatherInfoPanel } from './WeatherInfoPanel';

View File

@@ -0,0 +1,31 @@
import { useLive, type LiveEvent } from './useLive';
interface UseWeatherLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: WeatherLiveEvent) => void;
}
interface UseWeatherLiveReturn {
isConnected: boolean;
lastEvent: WeatherLiveEvent | null;
error: string | null;
}
export type WeatherLiveEvent = LiveEvent;
export function useWeatherLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseWeatherLiveOptions): UseWeatherLiveReturn {
return useLive({
sessionId,
apiPath: 'weather',
currentUserId,
enabled,
onEvent,
});
}

21
src/lib/date-utils.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Get ISO week number for a given date
* ISO 8601 week numbering: week starts on Monday, first week contains Jan 4
*/
export function getISOWeek(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/**
* Get week number and year for a given date
* Returns format: "S06-2026"
*/
export function getWeekYearLabel(date: Date = new Date()): string {
const week = getISOWeek(date);
const year = date.getFullYear();
return `S${week.toString().padStart(2, '0')}-${year}`;
}

View File

@@ -14,3 +14,45 @@ export function getCurrentQuarterPeriod(date: Date = new Date()): string {
export function isCurrentQuarterPeriod(period: string, date: Date = new Date()): boolean { export function isCurrentQuarterPeriod(period: string, date: Date = new Date()): boolean {
return period === getCurrentQuarterPeriod(date); return period === getCurrentQuarterPeriod(date);
} }
/**
* Parse a period string to extract year and quarter
* Returns { year, quarter } or null if format is not recognized
* Supports formats like "Q1 2025", "Q2 2024", etc.
*/
function parsePeriod(period: string): { year: number; quarter: number } | null {
// Match format "Q{quarter} {year}"
const match = period.match(/^Q(\d)\s+(\d{4})$/);
if (match) {
return {
year: parseInt(match[2], 10),
quarter: parseInt(match[1], 10),
};
}
return null;
}
/**
* Compare two period strings for sorting (most recent first)
* Returns negative if a should come before b, positive if after, 0 if equal
*/
export function comparePeriods(a: string, b: string): number {
const aParsed = parsePeriod(a);
const bParsed = parsePeriod(b);
// If both can be parsed, compare by year then quarter
if (aParsed && bParsed) {
// Most recent year first
const yearDiff = bParsed.year - aParsed.year;
if (yearDiff !== 0) return yearDiff;
// Most recent quarter first (same year)
return bParsed.quarter - aParsed.quarter;
}
// Fallback: if one can be parsed, prioritize it
if (aParsed && !bParsed) return -1;
if (!aParsed && bParsed) return 1;
// Both unparseable: fallback to string comparison (descending)
return b.localeCompare(a);
}

388
src/services/weather.ts Normal file
View File

@@ -0,0 +1,388 @@
import { prisma } from '@/services/database';
import type { ShareRole } from '@prisma/client';
// ============================================
// Weather Session CRUD
// ============================================
export async function getWeatherSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.weatherSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
entries: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.weatherSessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
entries: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
return allSessions;
}
export async function getWeatherSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.weatherSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
if (!session) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit };
}
// Check if user can access session (owner or shared)
export async function canAccessWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
// Check if user can edit session (owner or EDITOR role)
export async function canEditWeatherSession(sessionId: string, userId: string) {
const count = await prisma.weatherSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) {
return prisma.weatherSession.create({
data: {
...data,
date: data.date || new Date(),
userId,
},
include: {
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
}
export async function updateWeatherSession(
sessionId: string,
userId: string,
data: { title?: string; date?: Date }
) {
return prisma.weatherSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteWeatherSession(sessionId: string, userId: string) {
return prisma.weatherSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Weather Entry CRUD
// ============================================
export async function getWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.findUnique({
where: {
sessionId_userId: { sessionId, userId },
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function createOrUpdateWeatherEntry(
sessionId: string,
userId: string,
data: {
performanceEmoji?: string | null;
moralEmoji?: string | null;
fluxEmoji?: string | null;
valueCreationEmoji?: string | null;
notes?: string | null;
}
) {
return prisma.weatherEntry.upsert({
where: {
sessionId_userId: { sessionId, userId },
},
update: data,
create: {
sessionId,
userId,
...data,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function deleteWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.deleteMany({
where: { sessionId, userId },
});
}
// ============================================
// Session Sharing
// ============================================
export async function shareWeatherSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Find target user
const targetUser = await prisma.user.findUnique({
where: { email: targetEmail },
});
if (!targetUser) {
throw new Error('User not found');
}
// Can't share with yourself
if (targetUser.id === ownerId) {
throw new Error('Cannot share session with yourself');
}
// Create or update share
return prisma.weatherSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: targetUser.id },
},
update: { role },
create: {
sessionId,
userId: targetUser.id,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function shareWeatherSessionToTeam(
sessionId: string,
ownerId: string,
teamId: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Get team members
const teamMembers = await prisma.teamMember.findMany({
where: { teamId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
if (teamMembers.length === 0) {
throw new Error('Team has no members');
}
// Share with all team members (except owner)
const shares = await Promise.all(
teamMembers
.filter((tm) => tm.userId !== ownerId) // Don't share with yourself
.map((tm) =>
prisma.weatherSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: tm.userId },
},
update: { role },
create: {
sessionId,
userId: tm.userId,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
})
)
);
return shares;
}
export async function removeWeatherShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.weatherSessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getWeatherSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessWeatherSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.weatherSessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type WeatherSessionEventType =
| 'ENTRY_CREATED'
| 'ENTRY_UPDATED'
| 'ENTRY_DELETED'
| 'SESSION_UPDATED';
export async function createWeatherSessionEvent(
sessionId: string,
userId: string,
type: WeatherSessionEventType,
payload: Record<string, unknown>
) {
return prisma.weatherSessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getWeatherSessionEvents(sessionId: string, since?: Date) {
return prisma.weatherSessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestWeatherEventTimestamp(sessionId: string) {
const event = await prisma.weatherSessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}