From 53ee344ae773fa428a13c3655ac7143e22e4e2b9 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 14 Jan 2026 10:23:58 +0100 Subject: [PATCH] feat: add Weekly Check-in feature with models, UI components, and session management for enhanced team collaboration --- .../migration.sql | 103 +++++ prisma/schema.prisma | 89 +++++ src/actions/weekly-checkin.ts | 333 ++++++++++++++++ .../weekly-checkin/[id]/subscribe/route.ts | 122 ++++++ src/app/page.tsx | 104 +++++ src/app/sessions/WorkshopTabs.tsx | 96 ++++- src/app/sessions/page.tsx | 43 +- src/app/weekly-checkin/[id]/page.tsx | 101 +++++ src/app/weekly-checkin/new/page.tsx | 146 +++++++ .../ui/EditableWeeklyCheckInTitle.tsx | 28 ++ src/components/ui/index.ts | 1 + .../weekly-checkin/CurrentQuarterOKRs.tsx | 161 ++++++++ .../weekly-checkin/WeeklyCheckInBoard.tsx | 94 +++++ .../weekly-checkin/WeeklyCheckInCard.tsx | 200 ++++++++++ .../WeeklyCheckInLiveWrapper.tsx | 132 +++++++ .../weekly-checkin/WeeklyCheckInSection.tsx | 173 ++++++++ .../WeeklyCheckInShareModal.tsx | 172 ++++++++ src/components/weekly-checkin/index.ts | 6 + src/hooks/useWeeklyCheckInLive.ts | 31 ++ src/lib/okr-utils.ts | 16 + src/lib/types.ts | 216 ++++++++++ src/services/okrs.ts | 39 ++ src/services/weekly-checkin.ts | 371 ++++++++++++++++++ 23 files changed, 2753 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/20250115000000_add_weekly_check_in/migration.sql create mode 100644 src/actions/weekly-checkin.ts create mode 100644 src/app/api/weekly-checkin/[id]/subscribe/route.ts create mode 100644 src/app/weekly-checkin/[id]/page.tsx create mode 100644 src/app/weekly-checkin/new/page.tsx create mode 100644 src/components/ui/EditableWeeklyCheckInTitle.tsx create mode 100644 src/components/weekly-checkin/CurrentQuarterOKRs.tsx create mode 100644 src/components/weekly-checkin/WeeklyCheckInBoard.tsx create mode 100644 src/components/weekly-checkin/WeeklyCheckInCard.tsx create mode 100644 src/components/weekly-checkin/WeeklyCheckInLiveWrapper.tsx create mode 100644 src/components/weekly-checkin/WeeklyCheckInSection.tsx create mode 100644 src/components/weekly-checkin/WeeklyCheckInShareModal.tsx create mode 100644 src/components/weekly-checkin/index.ts create mode 100644 src/hooks/useWeeklyCheckInLive.ts create mode 100644 src/lib/okr-utils.ts create mode 100644 src/services/weekly-checkin.ts diff --git a/prisma/migrations/20250115000000_add_weekly_check_in/migration.sql b/prisma/migrations/20250115000000_add_weekly_check_in/migration.sql new file mode 100644 index 0000000..ffeee91 --- /dev/null +++ b/prisma/migrations/20250115000000_add_weekly_check_in/migration.sql @@ -0,0 +1,103 @@ +-- CreateEnum +CREATE TABLE "WeeklyCheckInCategory" ( + "value" TEXT NOT NULL PRIMARY KEY +); + +-- CreateEnum +CREATE TABLE "Emotion" ( + "value" TEXT NOT NULL PRIMARY KEY +); + +-- InsertEnumValues +INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WELL'); +INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('WENT_WRONG'); +INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('CURRENT_FOCUS'); +INSERT INTO "WeeklyCheckInCategory" ("value") VALUES ('NEXT_FOCUS'); + +-- InsertEnumValues +INSERT INTO "Emotion" ("value") VALUES ('PRIDE'); +INSERT INTO "Emotion" ("value") VALUES ('JOY'); +INSERT INTO "Emotion" ("value") VALUES ('SATISFACTION'); +INSERT INTO "Emotion" ("value") VALUES ('GRATITUDE'); +INSERT INTO "Emotion" ("value") VALUES ('CONFIDENCE'); +INSERT INTO "Emotion" ("value") VALUES ('FRUSTRATION'); +INSERT INTO "Emotion" ("value") VALUES ('WORRY'); +INSERT INTO "Emotion" ("value") VALUES ('DISAPPOINTMENT'); +INSERT INTO "Emotion" ("value") VALUES ('EXCITEMENT'); +INSERT INTO "Emotion" ("value") VALUES ('ANTICIPATION'); +INSERT INTO "Emotion" ("value") VALUES ('DETERMINATION'); +INSERT INTO "Emotion" ("value") VALUES ('NONE'); + +-- CreateTable +CREATE TABLE "WeeklyCheckInSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "participant" 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 "WeeklyCheckInSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "WeeklyCheckInItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "content" TEXT NOT NULL, + "category" TEXT NOT NULL, + "emotion" TEXT NOT NULL DEFAULT 'NONE', + "order" INTEGER NOT NULL DEFAULT 0, + "sessionId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "WeeklyCheckInItem_category_fkey" FOREIGN KEY ("category") REFERENCES "WeeklyCheckInCategory" ("value") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "WeeklyCheckInItem_emotion_fkey" FOREIGN KEY ("emotion") REFERENCES "Emotion" ("value") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "WCISessionShare" ( + "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 "WCISessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "WCISessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "WCISessionEvent" ( + "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 "WCISessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "WCISessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "WeeklyCheckInSession_userId_idx" ON "WeeklyCheckInSession"("userId"); + +-- CreateIndex +CREATE INDEX "WeeklyCheckInSession_date_idx" ON "WeeklyCheckInSession"("date"); + +-- CreateIndex +CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId"); + +-- CreateIndex +CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category"); + +-- CreateIndex +CREATE INDEX "WCISessionShare_sessionId_idx" ON "WCISessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "WCISessionShare_userId_idx" ON "WCISessionShare"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WCISessionShare_sessionId_userId_key" ON "WCISessionShare"("sessionId", "userId"); + +-- CreateIndex +CREATE INDEX "WCISessionEvent_sessionId_createdAt_idx" ON "WCISessionEvent"("sessionId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0023459..09131f2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,10 @@ model User { yearReviewSessions YearReviewSession[] sharedYearReviewSessions YRSessionShare[] yearReviewSessionEvents YRSessionEvent[] + // Weekly Check-in relations + weeklyCheckInSessions WeeklyCheckInSession[] + sharedWeeklyCheckInSessions WCISessionShare[] + weeklyCheckInSessionEvents WCISessionEvent[] // Teams & OKRs relations createdTeams Team[] teamMembers TeamMember[] @@ -365,3 +369,88 @@ model KeyResult { @@index([okrId]) @@index([okrId, order]) } + +// ============================================ +// Weekly Check-in Workshop +// ============================================ + +enum WeeklyCheckInCategory { + WENT_WELL // Ce qui s'est bien passé + WENT_WRONG // Ce qui s'est mal passé + CURRENT_FOCUS // Les enjeux du moment (je me concentre sur ...) + NEXT_FOCUS // Les prochains enjeux +} + +enum Emotion { + PRIDE // Fierté + JOY // Joie + SATISFACTION // Satisfaction + GRATITUDE // Gratitude + CONFIDENCE // Confiance + FRUSTRATION // Frustration + WORRY // Inquiétude + DISAPPOINTMENT // Déception + EXCITEMENT // Excitement + ANTICIPATION // Anticipation + DETERMINATION // Détermination + NONE // Aucune émotion +} + +model WeeklyCheckInSession { + id String @id @default(cuid()) + title String + participant String // Nom du participant + date DateTime @default(now()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + items WeeklyCheckInItem[] + shares WCISessionShare[] + events WCISessionEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([date]) +} + +model WeeklyCheckInItem { + id String @id @default(cuid()) + content String + category WeeklyCheckInCategory + emotion Emotion @default(NONE) + order Int @default(0) + sessionId String + session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([sessionId]) + @@index([sessionId, category]) +} + +model WCISessionShare { + id String @id @default(cuid()) + sessionId String + session WeeklyCheckInSession @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 WCISessionEvent { + id String @id @default(cuid()) + sessionId String + session WeeklyCheckInSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // ITEM_CREATED, ITEM_UPDATED, ITEM_DELETED, etc. + payload String // JSON payload + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/src/actions/weekly-checkin.ts b/src/actions/weekly-checkin.ts new file mode 100644 index 0000000..5c6cbc2 --- /dev/null +++ b/src/actions/weekly-checkin.ts @@ -0,0 +1,333 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { auth } from '@/lib/auth'; +import * as weeklyCheckInService from '@/services/weekly-checkin'; +import type { WeeklyCheckInCategory, Emotion } from '@prisma/client'; + +// ============================================ +// Session Actions +// ============================================ + +export async function createWeeklyCheckInSession(data: { + title: string; + participant: string; + date?: Date; +}) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const weeklyCheckInSession = await weeklyCheckInService.createWeeklyCheckInSession( + session.user.id, + data + ); + revalidatePath('/weekly-checkin'); + revalidatePath('/sessions'); + return { success: true, data: weeklyCheckInSession }; + } catch (error) { + console.error('Error creating weekly check-in session:', error); + return { success: false, error: 'Erreur lors de la création' }; + } +} + +export async function updateWeeklyCheckInSession( + sessionId: string, + data: { title?: string; participant?: string; date?: Date } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await weeklyCheckInService.updateWeeklyCheckInSession(sessionId, authSession.user.id, data); + + // Emit event for real-time sync + await weeklyCheckInService.createWeeklyCheckInSessionEvent( + sessionId, + authSession.user.id, + 'SESSION_UPDATED', + data + ); + + revalidatePath(`/weekly-checkin/${sessionId}`); + revalidatePath('/weekly-checkin'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error updating weekly check-in session:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteWeeklyCheckInSession(sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await weeklyCheckInService.deleteWeeklyCheckInSession(sessionId, authSession.user.id); + revalidatePath('/weekly-checkin'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error deleting weekly check-in session:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +// ============================================ +// Item Actions +// ============================================ + +export async function createWeeklyCheckInItem( + sessionId: string, + data: { content: string; category: WeeklyCheckInCategory; emotion?: Emotion } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + const item = await weeklyCheckInService.createWeeklyCheckInItem(sessionId, data); + + // Emit event for real-time sync + await weeklyCheckInService.createWeeklyCheckInSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_CREATED', + { + itemId: item.id, + content: item.content, + category: item.category, + emotion: item.emotion, + } + ); + + revalidatePath(`/weekly-checkin/${sessionId}`); + return { success: true, data: item }; + } catch (error) { + console.error('Error creating weekly check-in item:', error); + return { success: false, error: 'Erreur lors de la création' }; + } +} + +export async function updateWeeklyCheckInItem( + itemId: string, + sessionId: string, + data: { content?: string; category?: WeeklyCheckInCategory; emotion?: Emotion } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + const item = await weeklyCheckInService.updateWeeklyCheckInItem(itemId, data); + + // Emit event for real-time sync + await weeklyCheckInService.createWeeklyCheckInSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_UPDATED', + { + itemId: item.id, + ...data, + } + ); + + revalidatePath(`/weekly-checkin/${sessionId}`); + return { success: true, data: item }; + } catch (error) { + console.error('Error updating weekly check-in item:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteWeeklyCheckInItem(itemId: string, sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await weeklyCheckInService.deleteWeeklyCheckInItem(itemId); + + // Emit event for real-time sync + await weeklyCheckInService.createWeeklyCheckInSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_DELETED', + { itemId } + ); + + revalidatePath(`/weekly-checkin/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error deleting weekly check-in item:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +export async function moveWeeklyCheckInItem( + itemId: string, + sessionId: string, + newCategory: WeeklyCheckInCategory, + newOrder: number +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await weeklyCheckInService.moveWeeklyCheckInItem(itemId, newCategory, newOrder); + + // Emit event for real-time sync + await weeklyCheckInService.createWeeklyCheckInSessionEvent( + sessionId, + authSession.user.id, + 'ITEM_MOVED', + { + itemId, + category: newCategory, + order: newOrder, + } + ); + + revalidatePath(`/weekly-checkin/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error moving weekly check-in item:', error); + return { success: false, error: 'Erreur lors du déplacement' }; + } +} + +export async function reorderWeeklyCheckInItems( + sessionId: string, + category: WeeklyCheckInCategory, + itemIds: string[] +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + // Check edit permission + const canEdit = await weeklyCheckInService.canEditWeeklyCheckInSession( + sessionId, + authSession.user.id + ); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await weeklyCheckInService.reorderWeeklyCheckInItems(sessionId, category, itemIds); + + // Emit event for real-time sync + await weeklyCheckInService.createWeeklyCheckInSessionEvent( + sessionId, + authSession.user.id, + 'ITEMS_REORDERED', + { category, itemIds } + ); + + revalidatePath(`/weekly-checkin/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error reordering weekly check-in items:', error); + return { success: false, error: 'Erreur lors du réordonnancement' }; + } +} + +// ============================================ +// Sharing Actions +// ============================================ + +export async function shareWeeklyCheckInSession( + 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 weeklyCheckInService.shareWeeklyCheckInSession( + sessionId, + authSession.user.id, + targetEmail, + role + ); + revalidatePath(`/weekly-checkin/${sessionId}`); + return { success: true, data: share }; + } catch (error) { + console.error('Error sharing weekly check-in session:', error); + const message = error instanceof Error ? error.message : 'Erreur lors du partage'; + return { success: false, error: message }; + } +} + +export async function removeWeeklyCheckInShare(sessionId: string, shareUserId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await weeklyCheckInService.removeWeeklyCheckInShare( + sessionId, + authSession.user.id, + shareUserId + ); + revalidatePath(`/weekly-checkin/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error removing weekly check-in share:', error); + return { success: false, error: 'Erreur lors de la suppression du partage' }; + } +} diff --git a/src/app/api/weekly-checkin/[id]/subscribe/route.ts b/src/app/api/weekly-checkin/[id]/subscribe/route.ts new file mode 100644 index 0000000..ee44290 --- /dev/null +++ b/src/app/api/weekly-checkin/[id]/subscribe/route.ts @@ -0,0 +1,122 @@ +import { auth } from '@/lib/auth'; +import { + canAccessWeeklyCheckInSession, + getWeeklyCheckInSessionEvents, +} from '@/services/weekly-checkin'; + +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 canAccessWeeklyCheckInSession(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 getWeeklyCheckInSessionEvents(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 broadcastToWeeklyCheckInSession(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 2f572e2..fada1d9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -68,6 +68,22 @@ export default function Home() { accentColor="#f59e0b" newHref="/year-review/new" /> + + {/* Weekly Check-in Workshop Card */} + @@ -355,6 +371,94 @@ export default function Home() { + {/* Weekly Check-in Deep Dive Section */} +
+
+ 📝 +
+

Weekly Check-in

+

Le point hebdomadaire avec vos collaborateurs

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

+ 💡 + Pourquoi faire un check-in hebdomadaire ? +

+

+ Le Weekly Check-in est un rituel de management qui permet de maintenir un lien régulier + avec vos collaborateurs. Il favorise la communication, l'alignement et la détection + précoce des problèmes ou opportunités. +

+
    +
  • + + Maintenir un suivi régulier et structuré avec chaque collaborateur +
  • +
  • + + Identifier rapidement les points positifs et les difficultés rencontrées +
  • +
  • + + Comprendre les priorités et enjeux du moment pour mieux accompagner +
  • +
  • + + Créer un espace d'échange ouvert où les émotions peuvent être exprimées +
  • +
+
+ + {/* The 4 categories */} +
+

+ 📋 + Les 4 catégories du check-in +

+
+ + + + +
+
+ + {/* 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 fb3c80e..463ab1b 100644 --- a/src/app/sessions/WorkshopTabs.tsx +++ b/src/app/sessions/WorkshopTabs.tsx @@ -15,10 +15,18 @@ import { 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'; -type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'byPerson'; +type WorkshopType = 'all' | 'swot' | 'motivators' | 'year-review' | 'weekly-checkin' | 'byPerson'; -const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'year-review', 'byPerson']; +const VALID_TABS: WorkshopType[] = [ + 'all', + 'swot', + 'motivators', + 'year-review', + 'weekly-checkin', + 'byPerson', +]; interface ShareUser { id: string; @@ -84,12 +92,28 @@ interface YearReviewSession { workshopType: 'year-review'; } -type AnySession = SwotSession | MotivatorSession | YearReviewSession; +interface WeeklyCheckInSession { + id: string; + title: string; + participant: string; + resolvedParticipant: ResolvedCollaborator; + date: Date; + updatedAt: Date; + isOwner: boolean; + role: 'OWNER' | 'VIEWER' | 'EDITOR'; + user: { id: string; name: string | null; email: string }; + shares: Share[]; + _count: { items: number }; + workshopType: 'weekly-checkin'; +} + +type AnySession = SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession; interface WorkshopTabsProps { swotSessions: SwotSession[]; motivatorSessions: MotivatorSession[]; yearReviewSessions: YearReviewSession[]; + weeklyCheckInSessions: WeeklyCheckInSession[]; } // Helper to get resolved collaborator from any session @@ -98,6 +122,8 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator { return (session as SwotSession).resolvedCollaborator; } else if (session.workshopType === 'year-review') { return (session as YearReviewSession).resolvedParticipant; + } else if (session.workshopType === 'weekly-checkin') { + return (session as WeeklyCheckInSession).resolvedParticipant; } else { return (session as MotivatorSession).resolvedParticipant; } @@ -141,6 +167,7 @@ export function WorkshopTabs({ swotSessions, motivatorSessions, yearReviewSessions, + weeklyCheckInSessions, }: WorkshopTabsProps) { const searchParams = useSearchParams(); const router = useRouter(); @@ -165,6 +192,7 @@ export function WorkshopTabs({ ...swotSessions, ...motivatorSessions, ...yearReviewSessions, + ...weeklyCheckInSessions, ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); // Filter based on active tab (for non-byPerson tabs) @@ -175,7 +203,9 @@ export function WorkshopTabs({ ? swotSessions : activeTab === 'motivators' ? motivatorSessions - : yearReviewSessions; + : activeTab === 'year-review' + ? yearReviewSessions + : weeklyCheckInSessions; // Separate by ownership const ownedSessions = filteredSessions.filter((s) => s.isOwner); @@ -226,6 +256,13 @@ export function WorkshopTabs({ label="Year Review" count={yearReviewSessions.length} /> + setActiveTab('weekly-checkin')} + icon="📝" + label="Weekly Check-in" + count={weeklyCheckInSessions.length} + />
{/* Sessions */} @@ -343,18 +380,29 @@ function SessionCard({ session }: { session: AnySession }) { const isSwot = session.workshopType === 'swot'; const isYearReview = session.workshopType === 'year-review'; + const isWeeklyCheckIn = session.workshopType === 'weekly-checkin'; const href = isSwot ? `/sessions/${session.id}` : isYearReview ? `/year-review/${session.id}` - : `/motivators/${session.id}`; - const icon = isSwot ? '📊' : isYearReview ? '📅' : '🎯'; + : isWeeklyCheckIn + ? `/weekly-checkin/${session.id}` + : `/motivators/${session.id}`; + const icon = isSwot ? '📊' : isYearReview ? '📅' : isWeeklyCheckIn ? '📝' : '🎯'; const participant = isSwot ? (session as SwotSession).collaborator : isYearReview ? (session as YearReviewSession).participant - : (session as MotivatorSession).participant; - const accentColor = isSwot ? '#06b6d4' : isYearReview ? '#f59e0b' : '#8b5cf6'; + : isWeeklyCheckIn + ? (session as WeeklyCheckInSession).participant + : (session as MotivatorSession).participant; + const accentColor = isSwot + ? '#06b6d4' + : isYearReview + ? '#f59e0b' + : isWeeklyCheckIn + ? '#10b981' + : '#8b5cf6'; const handleDelete = () => { startTransition(async () => { @@ -362,7 +410,9 @@ function SessionCard({ session }: { session: AnySession }) { ? await deleteSwotSession(session.id) : isYearReview ? await deleteYearReviewSession(session.id) - : await deleteMotivatorSession(session.id); + : isWeeklyCheckIn + ? await deleteWeeklyCheckInSession(session.id) + : await deleteMotivatorSession(session.id); if (result.success) { setShowDeleteModal(false); @@ -381,10 +431,15 @@ function SessionCard({ session }: { session: AnySession }) { title: editTitle, participant: editParticipant, }) - : await updateMotivatorSession(session.id, { - title: editTitle, - participant: editParticipant, - }); + : isWeeklyCheckIn + ? await updateWeeklyCheckInSession(session.id, { + title: editTitle, + participant: editParticipant, + }) + : await updateMotivatorSession(session.id, { + title: editTitle, + participant: editParticipant, + }); if (result.success) { setShowEditModal(false); @@ -401,6 +456,8 @@ function SessionCard({ session }: { session: AnySession }) { setShowEditModal(true); }; + const editParticipantLabel = isSwot ? 'Collaborateur' : 'Participant'; + return ( <>
@@ -456,6 +513,17 @@ function SessionCard({ session }: { session: AnySession }) { · Année {(session as YearReviewSession).year} + ) : isWeeklyCheckIn ? ( + <> + {(session as WeeklyCheckInSession)._count.items} items + · + + {new Date((session as WeeklyCheckInSession).date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + + ) : ( {(session as MotivatorSession)._count.cards}/10 )} @@ -570,7 +638,7 @@ function SessionCard({ session }: { session: AnySession }) { htmlFor="edit-participant" className="block text-sm font-medium text-foreground mb-1" > - {isSwot ? 'Collaborateur' : 'Participant'} + {editParticipantLabel} ({ @@ -56,11 +59,17 @@ export default async function SessionsPage() { workshopType: 'year-review' as const, })); + const allWeeklyCheckInSessions = weeklyCheckInSessions.map((s) => ({ + ...s, + workshopType: 'weekly-checkin' as const, + })); + // Combine and sort by updatedAt const allSessions = [ ...allSwotSessions, ...allMotivatorSessions, ...allYearReviewSessions, + ...allWeeklyCheckInSessions, ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); const hasNoSessions = allSessions.length === 0; @@ -87,11 +96,17 @@ export default async function SessionsPage() { - + + +
@@ -104,9 +119,10 @@ export default async function SessionsPage() {

Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour - découvrir les motivations, ou un Year Review pour faire le bilan de l'année. + découvrir les motivations, un Year Review pour faire le bilan de l'année, ou un + Weekly Check-in pour le suivi hebdomadaire.

-
+
- + + +
) : ( @@ -133,6 +155,7 @@ export default async function SessionsPage() { swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} yearReviewSessions={allYearReviewSessions} + weeklyCheckInSessions={allWeeklyCheckInSessions} /> )} diff --git a/src/app/weekly-checkin/[id]/page.tsx b/src/app/weekly-checkin/[id]/page.tsx new file mode 100644 index 0000000..1970ee0 --- /dev/null +++ b/src/app/weekly-checkin/[id]/page.tsx @@ -0,0 +1,101 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { getWeeklyCheckInSessionById } from '@/services/weekly-checkin'; +import { getUserOKRsForPeriod } from '@/services/okrs'; +import { getCurrentQuarterPeriod } from '@/lib/okr-utils'; +import { WeeklyCheckInBoard, WeeklyCheckInLiveWrapper } from '@/components/weekly-checkin'; +import { CurrentQuarterOKRs } from '@/components/weekly-checkin/CurrentQuarterOKRs'; +import { Badge, CollaboratorDisplay } from '@/components/ui'; +import { EditableWeeklyCheckInTitle } from '@/components/ui'; + +interface WeeklyCheckInSessionPageProps { + params: Promise<{ id: string }>; +} + +export default async function WeeklyCheckInSessionPage({ params }: WeeklyCheckInSessionPageProps) { + const { id } = await params; + const authSession = await auth(); + + if (!authSession?.user?.id) { + return null; + } + + const session = await getWeeklyCheckInSessionById(id, authSession.user.id); + + if (!session) { + notFound(); + } + + // Get current quarter OKRs for the participant (NOT the creator) + // We use session.resolvedParticipant.matchedUser.id which is the participant's user ID + const currentQuarterPeriod = getCurrentQuarterPeriod(session.date); + let currentQuarterOKRs: Awaited> = []; + + // Only fetch OKRs if the participant is a recognized user (has matchedUser) + if (session.resolvedParticipant.matchedUser) { + // Use participant's ID, not session.userId (which is the creator's ID) + const participantUserId = session.resolvedParticipant.matchedUser.id; + currentQuarterOKRs = await getUserOKRsForPeriod(participantUserId, currentQuarterPeriod); + } + + return ( +
+ {/* Header */} +
+
+ + Weekly Check-in + + / + {session.title} + {!session.isOwner && ( + + Partagé par {session.user.name || session.user.email} + + )} +
+ +
+
+ +
+ +
+
+
+ {session.items.length} items + + {new Date(session.date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+
+ + {/* Current Quarter OKRs */} + {currentQuarterOKRs.length > 0 && ( + + )} + + {/* Live Wrapper + Board */} + + + +
+ ); +} diff --git a/src/app/weekly-checkin/new/page.tsx b/src/app/weekly-checkin/new/page.tsx new file mode 100644 index 0000000..56490f7 --- /dev/null +++ b/src/app/weekly-checkin/new/page.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Button, + Input, +} from '@/components/ui'; +import { createWeeklyCheckInSession } from '@/actions/weekly-checkin'; + +export default function NewWeeklyCheckInPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + 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; + + if (!title || !participant) { + setError('Veuillez remplir tous les champs'); + setLoading(false); + return; + } + + const result = await createWeeklyCheckInSession({ title, participant, date }); + + if (!result.success) { + setError(result.error || 'Une erreur est survenue'); + setLoading(false); + return; + } + + router.push(`/weekly-checkin/${result.data?.id}`); + } + + // Default date to today + const today = new Date().toISOString().split('T')[0]; + + return ( +
+ + + + 📝 + Nouveau Check-in Hebdomadaire + + + Créez un check-in hebdomadaire pour faire le point sur la semaine avec votre + collaborateur + + + + +
+ {error && ( +
+ {error} +
+ )} + + + + + +
+ + +
+ +
+

Comment ça marche ?

+
    +
  1. + Ce qui s'est bien passé : Notez les réussites et points + positifs de la semaine +
  2. +
  3. + Ce qui s'est mal passé : Identifiez les difficultés et + points d'amélioration +
  4. +
  5. + Enjeux du moment : Décrivez sur quoi vous vous concentrez + actuellement +
  6. +
  7. + Prochains enjeux : Définissez ce sur quoi vous allez vous + concentrer prochainement +
  8. +
+

+ 💡 Astuce : Ajoutez une émotion à chaque item pour mieux exprimer + votre ressenti (fierté, joie, frustration, etc.) +

+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/components/ui/EditableWeeklyCheckInTitle.tsx b/src/components/ui/EditableWeeklyCheckInTitle.tsx new file mode 100644 index 0000000..3725393 --- /dev/null +++ b/src/components/ui/EditableWeeklyCheckInTitle.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { EditableTitle } from './EditableTitle'; +import { updateWeeklyCheckInSession } from '@/actions/weekly-checkin'; + +interface EditableWeeklyCheckInTitleProps { + sessionId: string; + initialTitle: string; + isOwner: boolean; +} + +export function EditableWeeklyCheckInTitle({ + sessionId, + initialTitle, + isOwner, +}: EditableWeeklyCheckInTitleProps) { + return ( + { + const result = await updateWeeklyCheckInSession(id, { title }); + return result; + }} + /> + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 0581693..eb8d700 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -7,6 +7,7 @@ export { EditableTitle } from './EditableTitle'; export { EditableSessionTitle } from './EditableSessionTitle'; export { EditableMotivatorTitle } from './EditableMotivatorTitle'; export { EditableYearReviewTitle } from './EditableYearReviewTitle'; +export { EditableWeeklyCheckInTitle } from './EditableWeeklyCheckInTitle'; export { Input } from './Input'; export { Modal, ModalFooter } from './Modal'; export { Select } from './Select'; diff --git a/src/components/weekly-checkin/CurrentQuarterOKRs.tsx b/src/components/weekly-checkin/CurrentQuarterOKRs.tsx new file mode 100644 index 0000000..7274f44 --- /dev/null +++ b/src/components/weekly-checkin/CurrentQuarterOKRs.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'; +import { Badge } from '@/components/ui'; +import type { OKR } from '@/lib/types'; +import { OKR_STATUS_LABELS } from '@/lib/types'; + +type OKRWithTeam = OKR & { + team?: { + id: string; + name: string; + } | null; +}; + +interface CurrentQuarterOKRsProps { + okrs: OKRWithTeam[]; + period: string; +} + +export function CurrentQuarterOKRs({ okrs, period }: CurrentQuarterOKRsProps) { + const [isExpanded, setIsExpanded] = useState(true); + + if (okrs.length === 0) { + return null; + } + + return ( + + + + + + + {isExpanded && ( + +
+ {okrs.map((okr) => { + const statusColors = getOKRStatusColor(okr.status); + return ( +
+
+
+
+

{okr.objective}

+ + {OKR_STATUS_LABELS[okr.status]} + + {okr.progress !== undefined && ( + {okr.progress}% + )} +
+ {okr.description && ( +

{okr.description}

+ )} + {okr.keyResults && okr.keyResults.length > 0 && ( +
    + {okr.keyResults.slice(0, 3).map((kr) => { + const krProgress = kr.targetValue > 0 + ? Math.round((kr.currentValue / kr.targetValue) * 100) + : 0; + return ( +
  • + + {kr.title} + + {kr.currentValue}/{kr.targetValue} {kr.unit} + + ({krProgress}%) +
  • + ); + })} + {okr.keyResults.length > 3 && ( +
  • + +{okr.keyResults.length - 3} autre{okr.keyResults.length - 3 > 1 ? 's' : ''} +
  • + )} +
+ )} + {okr.team && ( +
+ Équipe: {okr.team.name} +
+ )} +
+
+
+ ); + })} +
+
+ + Voir tous les objectifs + + + + +
+
+ )} +
+ ); +} + +function getOKRStatusColor(status: OKR['status']): { bg: string; color: string } { + switch (status) { + case 'NOT_STARTED': + return { + bg: 'color-mix(in srgb, #6b7280 15%, transparent)', + color: '#6b7280', + }; + case 'IN_PROGRESS': + return { + bg: 'color-mix(in srgb, #3b82f6 15%, transparent)', + color: '#3b82f6', + }; + case 'COMPLETED': + return { + bg: 'color-mix(in srgb, #10b981 15%, transparent)', + color: '#10b981', + }; + case 'CANCELLED': + return { + bg: 'color-mix(in srgb, #ef4444 15%, transparent)', + color: '#ef4444', + }; + default: + return { + bg: 'color-mix(in srgb, #6b7280 15%, transparent)', + color: '#6b7280', + }; + } +} diff --git a/src/components/weekly-checkin/WeeklyCheckInBoard.tsx b/src/components/weekly-checkin/WeeklyCheckInBoard.tsx new file mode 100644 index 0000000..eb55e72 --- /dev/null +++ b/src/components/weekly-checkin/WeeklyCheckInBoard.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useTransition } from 'react'; +import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'; +import type { WeeklyCheckInItem, WeeklyCheckInCategory } from '@prisma/client'; +import { WeeklyCheckInSection } from './WeeklyCheckInSection'; +import { WeeklyCheckInCard } from './WeeklyCheckInCard'; +import { moveWeeklyCheckInItem, reorderWeeklyCheckInItems } from '@/actions/weekly-checkin'; +import { WEEKLY_CHECK_IN_SECTIONS } from '@/lib/types'; + +interface WeeklyCheckInBoardProps { + sessionId: string; + items: WeeklyCheckInItem[]; +} + +export function WeeklyCheckInBoard({ sessionId, items }: WeeklyCheckInBoardProps) { + const [isPending, startTransition] = useTransition(); + + const itemsByCategory = WEEKLY_CHECK_IN_SECTIONS.reduce( + (acc, section) => { + acc[section.category] = items + .filter((item) => item.category === section.category) + .sort((a, b) => a.order - b.order); + return acc; + }, + {} as Record + ); + + function handleDragEnd(result: DropResult) { + if (!result.destination) return; + + const { source, destination, draggableId } = result; + const sourceCategory = source.droppableId as WeeklyCheckInCategory; + const destCategory = destination.droppableId as WeeklyCheckInCategory; + + // If same position, do nothing + if (sourceCategory === destCategory && source.index === destination.index) { + return; + } + + startTransition(async () => { + if (sourceCategory === destCategory) { + // Same category - just reorder + const categoryItems = itemsByCategory[sourceCategory]; + const itemIds = categoryItems.map((item) => item.id); + const [removed] = itemIds.splice(source.index, 1); + itemIds.splice(destination.index, 0, removed); + await reorderWeeklyCheckInItems(sessionId, sourceCategory, itemIds); + } else { + // Different category - move item + await moveWeeklyCheckInItem(draggableId, sessionId, destCategory, destination.index); + } + }); + } + + return ( +
+ {/* Weekly Check-in Sections */} + +
+ {WEEKLY_CHECK_IN_SECTIONS.map((section) => ( + + {(provided, snapshot) => ( + + {itemsByCategory[section.category].map((item, index) => ( + + {(dragProvided, dragSnapshot) => ( + + )} + + ))} + {provided.placeholder} + + )} + + ))} +
+
+
+ ); +} diff --git a/src/components/weekly-checkin/WeeklyCheckInCard.tsx b/src/components/weekly-checkin/WeeklyCheckInCard.tsx new file mode 100644 index 0000000..33f0d00 --- /dev/null +++ b/src/components/weekly-checkin/WeeklyCheckInCard.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { forwardRef, useState, useTransition } from 'react'; +import type { WeeklyCheckInItem } from '@prisma/client'; +import { updateWeeklyCheckInItem, deleteWeeklyCheckInItem } from '@/actions/weekly-checkin'; +import { WEEKLY_CHECK_IN_BY_CATEGORY, EMOTION_BY_TYPE } from '@/lib/types'; +import { Select } from '@/components/ui/Select'; + +interface WeeklyCheckInCardProps { + item: WeeklyCheckInItem; + sessionId: string; + isDragging: boolean; +} + +export const WeeklyCheckInCard = forwardRef( + ({ item, sessionId, isDragging, ...props }, ref) => { + const [isEditing, setIsEditing] = useState(false); + const [content, setContent] = useState(item.content); + const [emotion, setEmotion] = useState(item.emotion); + const [isPending, startTransition] = useTransition(); + + const config = WEEKLY_CHECK_IN_BY_CATEGORY[item.category]; + const emotionConfig = EMOTION_BY_TYPE[item.emotion]; + + async function handleSave() { + if (content.trim() === item.content && emotion === item.emotion) { + setIsEditing(false); + return; + } + + if (!content.trim()) { + // If empty, delete + startTransition(async () => { + await deleteWeeklyCheckInItem(item.id, sessionId); + }); + return; + } + + startTransition(async () => { + await updateWeeklyCheckInItem(item.id, sessionId, { + content: content.trim(), + emotion, + }); + setIsEditing(false); + }); + } + + async function handleDelete() { + startTransition(async () => { + await deleteWeeklyCheckInItem(item.id, sessionId); + }); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + setContent(item.content); + setEmotion(item.emotion); + setIsEditing(false); + } + } + + return ( +
+ {isEditing ? ( +
{ + // Don't close if focus moves to another element in this container + const currentTarget = e.currentTarget; + const relatedTarget = e.relatedTarget as Node | null; + if (relatedTarget && currentTarget.contains(relatedTarget)) { + return; + } + // Only save on blur if content changed + if (content.trim() !== item.content || emotion !== item.emotion) { + handleSave(); + } else { + setIsEditing(false); + } + }} + > +