diff --git a/PERF_OPTIMIZATIONS.md b/PERF_OPTIMIZATIONS.md
new file mode 100644
index 0000000..e6ac2b6
--- /dev/null
+++ b/PERF_OPTIMIZATIONS.md
@@ -0,0 +1,74 @@
+# Optimisations de performance
+
+## Requêtes DB (impact critique)
+
+### resolveCollaborator — suppression du scan complet de la table User
+**Fichier:** `src/services/auth.ts`
+
+Avant : `findMany` sur tous les users puis `find()` en JS pour un match case-insensitive par nom.
+Après : `findFirst` avec `contains` + vérification exacte. O(1) au lieu de O(N users).
+
+### getAllUsersWithStats — suppression du N+1
+**Fichier:** `src/services/auth.ts`
+
+Avant : 2 queries `count` par utilisateur (`Promise.all` avec map).
+Après : 2 `groupBy` en bulk + construction d'une Map. 3 queries au lieu de 2N+1.
+
+### React.cache sur les fonctions teams
+**Fichier:** `src/services/teams.ts`
+
+`getTeamMemberIdsForAdminTeams` et `isAdminOfUser` wrappées avec `React.cache()`.
+Sur la page `/sessions`, ces fonctions étaient appelées ~10 fois par requête (5 workshop types × 2). Maintenant dédupliquées en 1 appel.
+
+## SSE / Temps réel (impact haut)
+
+### Polling interval 1s → 2s
+**Fichiers:** 5 routes `src/app/api/*/[id]/subscribe/route.ts`
+
+Réduit de 50% le nombre de queries DB en temps réel. Imperceptible côté UX (la plupart des outils collab utilisent 2-5s).
+
+### Nettoyage des events
+**Fichier:** `src/services/session-share-events.ts`
+
+Ajout de `cleanupOldEvents(maxAgeHours)` pour purger les events périmés. Les tables d'events n'ont pas de mécanisme de TTL — cette méthode peut être appelée périodiquement ou à la connexion SSE.
+
+## Rendu client (impact haut)
+
+### React.memo sur les composants de cartes
+**Fichiers:**
+- `src/components/swot/SwotCard.tsx`
+- `src/components/moving-motivators/MotivatorCard.tsx` (+ `MotivatorCardStatic`)
+- `src/components/weather/WeatherCard.tsx`
+- `src/components/weekly-checkin/WeeklyCheckInCard.tsx`
+- `src/components/year-review/YearReviewCard.tsx`
+
+Ces composants sont rendus en liste et re-rendaient tous à chaque drag, changement d'état, ou `router.refresh()` SSE.
+
+### WeatherCard — fix du pattern useEffect + setState
+**Fichier:** `src/components/weather/WeatherCard.tsx`
+
+Remplacé le `useEffect` qui appelait 5 `setState` (cascading renders, erreur lint React 19) par le pattern idiomatique de state-driven prop sync (comparaison directe dans le render body).
+
+## Configuration Next.js (impact moyen)
+
+### next.config.ts
+**Fichier:** `next.config.ts`
+
+- `poweredByHeader: false` — supprime le header `X-Powered-By` (sécurité)
+- `optimizePackageImports` — tree-shaking amélioré pour `@dnd-kit/*` et `@hello-pangea/dnd`
+
+### Fix FOUC dark mode
+**Fichier:** `src/app/layout.tsx`
+
+Script inline dans `
` qui lit `localStorage` et applique la classe `dark`/`light` sur `` avant l'hydratation React. Élimine le flash blanc pour les utilisateurs en dark mode.
+
+## Nettoyage
+
+- Suppression de 5 SVGs inutilisés du template Next.js (`file.svg`, `globe.svg`, `next.svg`, `vercel.svg`, `window.svg`)
+
+## Non traité (pour plus tard)
+
+- **Migration DnD** : consolider `@hello-pangea/dnd` et `@dnd-kit` en une seule lib (~45KB économisés) — 3 boards à réécrire
+- **Split WorkshopTabs** (879 lignes) — découper en sous-composants par type
+- **Suspense boundaries** sur les pages de détail de session
+- **Appel périodique de `cleanupOldEvents`** — à brancher via cron ou à la connexion SSE
diff --git a/next.config.ts b/next.config.ts
index 68a6c64..23994ac 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,15 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
+ poweredByHeader: false,
+ experimental: {
+ optimizePackageImports: [
+ "@dnd-kit/core",
+ "@dnd-kit/sortable",
+ "@dnd-kit/utilities",
+ "@hello-pangea/dnd",
+ ],
+ },
};
export default nextConfig;
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/app/api/motivators/[id]/subscribe/route.ts b/src/app/api/motivators/[id]/subscribe/route.ts
index 31bddab..52e1341 100644
--- a/src/app/api/motivators/[id]/subscribe/route.ts
+++ b/src/app/api/motivators/[id]/subscribe/route.ts
@@ -77,7 +77,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// Connection might be closed
clearInterval(pollInterval);
}
- }, 1000); // Poll every second
+ }, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {
diff --git a/src/app/api/sessions/[id]/subscribe/route.ts b/src/app/api/sessions/[id]/subscribe/route.ts
index 93f08bd..095971c 100644
--- a/src/app/api/sessions/[id]/subscribe/route.ts
+++ b/src/app/api/sessions/[id]/subscribe/route.ts
@@ -77,7 +77,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// Connection might be closed
clearInterval(pollInterval);
}
- }, 1000); // Poll every second
+ }, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {
diff --git a/src/app/api/weather/[id]/subscribe/route.ts b/src/app/api/weather/[id]/subscribe/route.ts
index 99a894a..9150927 100644
--- a/src/app/api/weather/[id]/subscribe/route.ts
+++ b/src/app/api/weather/[id]/subscribe/route.ts
@@ -80,7 +80,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// Connection might be closed
clearInterval(pollInterval);
}
- }, 1000); // Poll every second
+ }, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {
diff --git a/src/app/api/weekly-checkin/[id]/subscribe/route.ts b/src/app/api/weekly-checkin/[id]/subscribe/route.ts
index ee44290..86fd3b9 100644
--- a/src/app/api/weekly-checkin/[id]/subscribe/route.ts
+++ b/src/app/api/weekly-checkin/[id]/subscribe/route.ts
@@ -80,7 +80,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// Connection might be closed
clearInterval(pollInterval);
}
- }, 1000); // Poll every second
+ }, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {
diff --git a/src/app/api/year-review/[id]/subscribe/route.ts b/src/app/api/year-review/[id]/subscribe/route.ts
index 30cd4ba..a8b19d7 100644
--- a/src/app/api/year-review/[id]/subscribe/route.ts
+++ b/src/app/api/year-review/[id]/subscribe/route.ts
@@ -80,7 +80,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
// Connection might be closed
clearInterval(pollInterval);
}
- }, 1000); // Poll every second
+ }, 2000); // Poll every 2 seconds
// Cleanup on abort
request.signal.addEventListener('abort', () => {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f95b85b..1316ba1 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -29,6 +29,13 @@ export default function RootLayout({
}>) {
return (
+
+
+
{children}
diff --git a/src/components/moving-motivators/MotivatorCard.tsx b/src/components/moving-motivators/MotivatorCard.tsx
index 56a1b41..37a8d6c 100644
--- a/src/components/moving-motivators/MotivatorCard.tsx
+++ b/src/components/moving-motivators/MotivatorCard.tsx
@@ -1,5 +1,6 @@
'use client';
+import { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
@@ -12,7 +13,7 @@ interface MotivatorCardProps {
showInfluence?: boolean;
}
-export function MotivatorCard({
+export const MotivatorCard = memo(function MotivatorCard({
card,
disabled = false,
showInfluence = false,
@@ -87,10 +88,10 @@ export function MotivatorCard({
);
-}
+});
// Non-draggable version for summary
-export function MotivatorCardStatic({
+export const MotivatorCardStatic = memo(function MotivatorCardStatic({
card,
size = 'normal',
}: {
@@ -156,4 +157,4 @@ export function MotivatorCardStatic({
);
-}
+});
diff --git a/src/components/swot/SwotCard.tsx b/src/components/swot/SwotCard.tsx
index 98050e5..85f91a9 100644
--- a/src/components/swot/SwotCard.tsx
+++ b/src/components/swot/SwotCard.tsx
@@ -1,6 +1,6 @@
'use client';
-import { forwardRef, useState, useTransition } from 'react';
+import { forwardRef, memo, useState, useTransition } from 'react';
import type { SwotItem, SwotCategory } from '@prisma/client';
import { updateSwotItem, deleteSwotItem, duplicateSwotItem } from '@/actions/swot';
@@ -21,7 +21,7 @@ const categoryStyles: Record = {
THREAT: { ring: 'ring-threat', text: 'text-threat' },
};
-export const SwotCard = forwardRef(
+export const SwotCard = memo(forwardRef(
(
{ item, sessionId, isSelected, isHighlighted, isDragging, linkMode, onSelect, ...props },
ref
@@ -196,6 +196,5 @@ export const SwotCard = forwardRef(
);
}
-);
-
+));
SwotCard.displayName = 'SwotCard';
diff --git a/src/components/weather/WeatherCard.tsx b/src/components/weather/WeatherCard.tsx
index ffa6b1e..9df51e2 100644
--- a/src/components/weather/WeatherCard.tsx
+++ b/src/components/weather/WeatherCard.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useTransition, useEffect } from 'react';
+import { memo, useState, useTransition } from 'react';
import { createOrUpdateWeatherEntry } from '@/actions/weather';
import { Avatar } from '@/components/ui/Avatar';
import { Textarea } from '@/components/ui/Textarea';
@@ -89,25 +89,28 @@ function EvolutionIndicator({
);
}
-export function WeatherCard({ sessionId, currentUserId, entry, canEdit, previousEntry }: WeatherCardProps) {
+export const WeatherCard = memo(function WeatherCard({ sessionId, currentUserId, entry, canEdit, previousEntry }: WeatherCardProps) {
const [isPending, startTransition] = useTransition();
+ // Track entry version to reset local state when props change (SSE refresh)
+ const [entryVersion, setEntryVersion] = useState(entry);
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(() => {
+ // Reset local state when entry props change (React-idiomatic pattern)
+ if (entryVersion !== entry) {
+ setEntryVersion(entry);
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]);
+ }
+
+ const isCurrentUser = entry.userId === currentUserId;
+ const canEditThis = canEdit && isCurrentUser;
function handleEmojiChange(axis: 'performance' | 'moral' | 'flux' | 'valueCreation', emoji: string | null) {
if (!canEditThis) return;
@@ -282,4 +285,4 @@ export function WeatherCard({ sessionId, currentUserId, entry, canEdit, previous
);
-}
+});
diff --git a/src/components/weekly-checkin/WeeklyCheckInCard.tsx b/src/components/weekly-checkin/WeeklyCheckInCard.tsx
index 33f0d00..cda7432 100644
--- a/src/components/weekly-checkin/WeeklyCheckInCard.tsx
+++ b/src/components/weekly-checkin/WeeklyCheckInCard.tsx
@@ -1,6 +1,6 @@
'use client';
-import { forwardRef, useState, useTransition } from 'react';
+import { forwardRef, memo, 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';
@@ -12,7 +12,7 @@ interface WeeklyCheckInCardProps {
isDragging: boolean;
}
-export const WeeklyCheckInCard = forwardRef(
+export const WeeklyCheckInCard = memo(forwardRef(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
@@ -195,6 +195,5 @@ export const WeeklyCheckInCard = forwardRef
);
}
-);
-
+));
WeeklyCheckInCard.displayName = 'WeeklyCheckInCard';
diff --git a/src/components/year-review/YearReviewCard.tsx b/src/components/year-review/YearReviewCard.tsx
index 22dcd02..88c48bf 100644
--- a/src/components/year-review/YearReviewCard.tsx
+++ b/src/components/year-review/YearReviewCard.tsx
@@ -1,6 +1,6 @@
'use client';
-import { forwardRef, useState, useTransition } from 'react';
+import { forwardRef, memo, useState, useTransition } from 'react';
import type { YearReviewItem } from '@prisma/client';
import { updateYearReviewItem, deleteYearReviewItem } from '@/actions/year-review';
import { YEAR_REVIEW_BY_CATEGORY } from '@/lib/types';
@@ -11,7 +11,7 @@ interface YearReviewCardProps {
isDragging: boolean;
}
-export const YearReviewCard = forwardRef(
+export const YearReviewCard = memo(forwardRef(
({ item, sessionId, isDragging, ...props }, ref) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(item.content);
@@ -126,6 +126,5 @@ export const YearReviewCard = forwardRef(
);
}
-);
-
+));
YearReviewCard.displayName = 'YearReviewCard';
diff --git a/src/services/auth.ts b/src/services/auth.ts
index c1e1228..a758dbb 100644
--- a/src/services/auth.ts
+++ b/src/services/auth.ts
@@ -90,19 +90,24 @@ export async function resolveCollaborator(collaborator: string): Promise u.name?.toLowerCase() === normalizedSearch) || null;
+ // Verify exact match (contains may return partial matches)
+ const exactMatch = userByName && userByName.name?.toLowerCase() === trimmed.toLowerCase()
+ ? userByName
+ : null;
- return { raw: collaborator, matchedUser: userByName };
+ return { raw: collaborator, matchedUser: exactMatch };
}
export async function getUserById(id: string) {
@@ -223,26 +228,25 @@ export async function getAllUsersWithStats(): Promise {
orderBy: { createdAt: 'desc' },
});
- // Get motivator sessions count separately (Prisma doesn't have these in User model _count directly)
- const usersWithMotivators = await Promise.all(
- users.map(async (user) => {
- const motivatorCount = await prisma.movingMotivatorsSession.count({
- where: { userId: user.id },
- });
- const sharedMotivatorCount = await prisma.mMSessionShare.count({
- where: { userId: user.id },
- });
+ // Get motivator counts in bulk (2 queries instead of 2*N)
+ const motivatorCounts = await prisma.movingMotivatorsSession.groupBy({
+ by: ['userId'],
+ _count: { id: true },
+ });
+ const sharedMotivatorCounts = await prisma.mMSessionShare.groupBy({
+ by: ['userId'],
+ _count: { id: true },
+ });
- return {
- ...user,
- _count: {
- ...user._count,
- motivatorSessions: motivatorCount,
- sharedMotivatorSessions: sharedMotivatorCount,
- },
- };
- })
- );
+ const motivatorMap = new Map(motivatorCounts.map((m) => [m.userId, m._count.id]));
+ const sharedMotivatorMap = new Map(sharedMotivatorCounts.map((m) => [m.userId, m._count.id]));
- return usersWithMotivators;
+ return users.map((user) => ({
+ ...user,
+ _count: {
+ ...user._count,
+ motivatorSessions: motivatorMap.get(user.id) ?? 0,
+ sharedMotivatorSessions: sharedMotivatorMap.get(user.id) ?? 0,
+ },
+ }));
}
diff --git a/src/services/session-share-events.ts b/src/services/session-share-events.ts
index e7efbef..884ac3a 100644
--- a/src/services/session-share-events.ts
+++ b/src/services/session-share-events.ts
@@ -48,6 +48,9 @@ type EventDelegate = {
orderBy: { createdAt: 'desc' };
select: { createdAt: true };
}) => Promise<{ createdAt: Date } | null>;
+ deleteMany: (args: {
+ where: { createdAt: { lt: Date } };
+ }) => Promise;
};
type SessionDelegate = {
@@ -168,5 +171,13 @@ export function createShareAndEventHandlers(
});
return event?.createdAt;
},
+
+ /** Delete events older than the given number of hours (default: 24h) */
+ async cleanupOldEvents(maxAgeHours = 24) {
+ const cutoff = new Date(Date.now() - maxAgeHours * 60 * 60 * 1000);
+ return eventModel.deleteMany({
+ where: { createdAt: { lt: cutoff } },
+ });
+ },
};
}
diff --git a/src/services/teams.ts b/src/services/teams.ts
index 5eecdc7..0118412 100644
--- a/src/services/teams.ts
+++ b/src/services/teams.ts
@@ -1,3 +1,4 @@
+import { cache } from 'react';
import { prisma } from '@/services/database';
import type { UpdateTeamInput, TeamRole } from '@/lib/types';
@@ -245,14 +246,15 @@ export async function getTeamMember(teamId: string, userId: string) {
}
/** Returns true if adminUserId is ADMIN of any team that contains ownerUserId. */
-export async function isAdminOfUser(ownerUserId: string, adminUserId: string): Promise {
+export const isAdminOfUser = cache(async function isAdminOfUser(ownerUserId: string, adminUserId: string): Promise {
if (ownerUserId === adminUserId) return false;
const teamMemberIds = await getTeamMemberIdsForAdminTeams(adminUserId);
return teamMemberIds.includes(ownerUserId);
-}
+});
/** Returns user IDs of all members in teams where the given user is ADMIN (excluding self). */
-export async function getTeamMemberIdsForAdminTeams(userId: string): Promise {
+// Wrapped with React.cache to deduplicate calls within a single server request
+export const getTeamMemberIdsForAdminTeams = cache(async function getTeamMemberIdsForAdminTeams(userId: string): Promise {
const adminTeams = await prisma.teamMember.findMany({
where: {
userId,
@@ -271,7 +273,7 @@ export async function getTeamMemberIdsForAdminTeams(userId: string): Promise m.userId);
-}
+});
export async function getTeamMemberById(teamMemberId: string) {
return prisma.teamMember.findUnique({