Files
workshop-manager/src/app/sessions/page.tsx
Froidefond Julien 3d4803f975
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m33s
perf(realtime+data): implement perf-data-optimization and perf-realtime-scale
## perf-data-optimization
- Add @@index([name]) on User model (migration)
- Add WEATHER_HISTORY_LIMIT=90 constant, apply take/orderBy on weather history queries
- Replace deep includes with explicit select on all 6 list service queries
- Add unstable_cache layer with revalidateTag on all list service functions
- Add cache-tags.ts helpers (sessionTag, sessionsListTag, userStatsTag)
- Invalidate sessionsListTag in all create/delete Server Actions

## perf-realtime-scale
- Create src/lib/broadcast.ts: generic createBroadcaster factory with shared polling
  (one interval per active session, starts on first subscriber, stops on last)
- Migrate all 6 SSE routes to use createBroadcaster — removes per-connection setInterval
- Add broadcastToXxx() calls in all Server Actions after mutations for immediate push
- Add SESSIONS_PAGE_SIZE=20, pagination on sessions page with loadMoreSessions action
- Add "Charger plus" button with loading state and "X sur Y" counter in WorkshopTabs

## Tests
- Add 19 unit tests for broadcast.ts (polling lifecycle, userId filtering,
  formatEvent, error resilience, session isolation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:30:54 +01:00

179 lines
6.5 KiB
TypeScript

import { Suspense } from 'react';
import { auth } from '@/lib/auth';
import {
getSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamSwotSessions,
} from '@/services/sessions';
import {
getMotivatorSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamMotivatorSessions,
} from '@/services/moving-motivators';
import {
getYearReviewSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamYearReviewSessions,
} from '@/services/year-review';
import {
getWeeklyCheckInSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeeklyCheckInSessions,
} from '@/services/weekly-checkin';
import {
getWeatherSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions,
} from '@/services/weather';
import {
getGifMoodSessionsByUserId,
getTeamCollaboratorSessionsForAdmin as getTeamGifMoodSessions,
} from '@/services/gif-mood';
import { Card, PageHeader } from '@/components/ui';
import { withWorkshopType } from '@/lib/workshops';
import { SESSIONS_PAGE_SIZE } from '@/lib/types';
import { WorkshopTabs } from './WorkshopTabs';
import { NewWorkshopDropdown } from './NewWorkshopDropdown';
function WorkshopTabsSkeleton() {
return (
<div className="space-y-6">
{/* Tabs skeleton */}
<div className="flex gap-2 pb-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-9 w-28 bg-card animate-pulse rounded-full" />
))}
</div>
{/* Cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-44 bg-card animate-pulse rounded-xl" />
))}
</div>
</div>
);
}
export default async function SessionsPage() {
const session = await auth();
if (!session?.user?.id) {
return null;
}
// Fetch sessions (owned + shared) and team collab sessions (for team admins, non-shared)
const [
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
gifMoodSessions,
teamSwotSessions,
teamMotivatorSessions,
teamYearReviewSessions,
teamWeeklyCheckInSessions,
teamWeatherSessions,
teamGifMoodSessions,
] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
getYearReviewSessionsByUserId(session.user.id),
getWeeklyCheckInSessionsByUserId(session.user.id),
getWeatherSessionsByUserId(session.user.id),
getGifMoodSessionsByUserId(session.user.id),
getTeamSwotSessions(session.user.id),
getTeamMotivatorSessions(session.user.id),
getTeamYearReviewSessions(session.user.id),
getTeamWeeklyCheckInSessions(session.user.id),
getTeamWeatherSessions(session.user.id),
getTeamGifMoodSessions(session.user.id),
]);
// Track totals before slicing for pagination UI
const totals = {
swot: swotSessions.length,
motivators: motivatorSessions.length,
'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length,
'gif-mood': gifMoodSessions.length,
};
// Add workshopType and slice first page
const allSwotSessions = withWorkshopType(swotSessions.slice(0, SESSIONS_PAGE_SIZE), 'swot');
const allMotivatorSessions = withWorkshopType(motivatorSessions.slice(0, SESSIONS_PAGE_SIZE), 'motivators');
const allYearReviewSessions = withWorkshopType(yearReviewSessions.slice(0, SESSIONS_PAGE_SIZE), 'year-review');
const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions.slice(0, SESSIONS_PAGE_SIZE), 'weekly-checkin');
const allWeatherSessions = withWorkshopType(weatherSessions.slice(0, SESSIONS_PAGE_SIZE), 'weather');
const allGifMoodSessions = withWorkshopType(gifMoodSessions.slice(0, SESSIONS_PAGE_SIZE), 'gif-mood');
const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot');
const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators');
const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review');
const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin');
const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather');
const teamGifMoodWithType = withWorkshopType(teamGifMoodSessions, 'gif-mood');
// Combine and sort by updatedAt
const allSessions = [
...allSwotSessions,
...allMotivatorSessions,
...allYearReviewSessions,
...allWeeklyCheckInSessions,
...allWeatherSessions,
...allGifMoodSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const hasNoSessions = allSessions.length === 0;
const totalCount = allSessions.length;
return (
<main className="mx-auto max-w-7xl px-4">
<PageHeader
emoji="🗂️"
title="Mes Ateliers"
subtitle={
totalCount > 0
? `${totalCount} atelier${totalCount > 1 ? 's' : ''} · Tous vos ateliers en un seul endroit`
: 'Tous vos ateliers en un seul endroit'
}
actions={<NewWorkshopDropdown />}
/>
{/* Content */}
{hasNoSessions ? (
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🚀</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, un Moving Motivators pour
découvrir les motivations, un Year Review pour faire le bilan de l&apos;année, ou un
Weekly Check-in pour le suivi hebdomadaire.
</p>
<div className="flex justify-center">
<NewWorkshopDropdown />
</div>
</Card>
) : (
<Suspense fallback={<WorkshopTabsSkeleton />}>
<WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
yearReviewSessions={allYearReviewSessions}
weeklyCheckInSessions={allWeeklyCheckInSessions}
weatherSessions={allWeatherSessions}
gifMoodSessions={allGifMoodSessions}
totals={totals}
teamCollabSessions={[
...teamSwotWithType,
...teamMotivatorWithType,
...teamYearReviewWithType,
...teamWeeklyCheckInWithType,
...teamWeatherWithType,
...teamGifMoodWithType,
]}
/>
</Suspense>
)}
</main>
);
}