perf(quick-wins): batch collaborator resolution, debounce SSE refresh, loading states

- Eliminate N+1 on resolveCollaborator: add batchResolveCollaborators() in
  auth.ts (2 DB queries max regardless of session count), update all 4
  workshop services to use post-batch mapping
- Debounce router.refresh() in useLive.ts (300ms) to group simultaneous
  SSE events and avoid cascade re-renders
- Call cleanupOldEvents fire-and-forget in createEvent to purge old SSE
  events inline without blocking the response
- Add loading.tsx skeletons on /sessions and /users matching actual page
  layout (PageHeader + content structure)
- Lazy-load ShareModal via next/dynamic in BaseSessionLiveWrapper to reduce
  initial JS bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 08:07:22 +01:00
parent 2d266f89f9
commit a8c05aa841
10 changed files with 196 additions and 33 deletions

View File

@@ -0,0 +1,32 @@
export default function SessionsLoading() {
return (
<main className="mx-auto max-w-7xl px-4">
{/* PageHeader skeleton */}
<div className="py-6 flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-card rounded-xl animate-pulse" />
<div className="space-y-2">
<div className="h-7 w-40 bg-card rounded animate-pulse" />
<div className="h-4 w-64 bg-card rounded animate-pulse" />
</div>
</div>
<div className="h-9 w-36 bg-card rounded-lg animate-pulse" />
</div>
<div className="space-y-6">
{/* Tabs skeleton */}
<div className="flex gap-2 pb-2">
{[120, 100, 110, 90, 105].map((w, i) => (
<div key={i} className="h-9 bg-card animate-pulse rounded-full" style={{ width: w }} />
))}
</div>
{/* Cards grid skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-44 bg-card animate-pulse rounded-xl border border-border" />
))}
</div>
</div>
</main>
);
}

42
src/app/users/loading.tsx Normal file
View File

@@ -0,0 +1,42 @@
export default function UsersLoading() {
return (
<main className="mx-auto max-w-6xl px-4">
{/* PageHeader skeleton */}
<div className="py-6 flex items-start gap-3">
<div className="h-10 w-10 bg-card rounded-xl animate-pulse" />
<div className="space-y-2">
<div className="h-7 w-36 bg-card rounded animate-pulse" />
<div className="h-4 w-72 bg-card rounded animate-pulse" />
</div>
</div>
{/* Stats grid skeleton */}
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-4 space-y-2 animate-pulse">
<div className="h-8 w-12 bg-muted/40 rounded" />
<div className="h-4 w-24 bg-muted/30 rounded" />
</div>
))}
</div>
{/* User rows skeleton */}
<div className="space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 rounded-xl border border-border bg-card p-4 animate-pulse">
<div className="h-12 w-12 rounded-full bg-muted/40 flex-shrink-0" />
<div className="flex-1 space-y-2 min-w-0">
<div className="h-4 w-32 bg-muted/40 rounded" />
<div className="h-3 w-48 bg-muted/30 rounded" />
</div>
<div className="hidden sm:flex gap-2">
<div className="h-6 w-16 bg-muted/30 rounded-full" />
<div className="h-6 w-16 bg-muted/30 rounded-full" />
<div className="h-6 w-16 bg-muted/30 rounded-full" />
</div>
</div>
))}
</div>
</main>
);
}

View File

@@ -1,10 +1,12 @@
'use client';
import { useState, useCallback } from 'react';
import dynamic from 'next/dynamic';
import { useLive, type LiveEvent } from '@/hooks/useLive';
import { CollaborationToolbar } from './CollaborationToolbar';
import { ShareModal } from './ShareModal';
import type { ShareRole } from '@prisma/client';
const ShareModal = dynamic(() => import('./ShareModal').then((m) => m.ShareModal), { ssr: false });
import type { TeamWithMembers, Share } from '@/lib/share-utils';
export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin' | 'gif-mood';

View File

@@ -38,6 +38,7 @@ export function useLive({
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const onEventRef = useRef(onEvent);
const currentUserIdRef = useRef(currentUserId);
@@ -88,8 +89,9 @@ export function useLive({
setLastEvent(data);
onEventRef.current?.(data);
// Refresh the page data when we receive an event from another user
router.refresh();
// Debounce refresh to group simultaneous SSE events (~300ms window)
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = setTimeout(() => router.refresh(), 300);
} catch (e) {
console.error('Failed to parse SSE event:', e);
}
@@ -126,6 +128,10 @@ export function useLive({
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
};
}, [sessionId, apiPath, enabled, router]);

View File

@@ -74,6 +74,48 @@ export interface ResolvedCollaborator {
} | null;
}
// Batch resolve multiple collaborator strings — 2 DB queries max regardless of count
export async function batchResolveCollaborators(
collaborators: string[]
): Promise<Map<string, ResolvedCollaborator>> {
if (collaborators.length === 0) return new Map();
const unique = [...new Set(collaborators.map((c) => c.trim()))];
const emails = unique.filter(isEmail).map((e) => e.toLowerCase());
const names = unique.filter((c) => !isEmail(c));
const [byEmail, byName] = await Promise.all([
emails.length > 0
? prisma.user.findMany({
where: { email: { in: emails } },
select: { id: true, email: true, name: true },
})
: [],
names.length > 0
? prisma.user.findMany({
where: { OR: names.map((n) => ({ name: { contains: n } })) },
select: { id: true, email: true, name: true },
})
: [],
]);
const emailMap = new Map(byEmail.map((u) => [u.email.toLowerCase(), u]));
const nameMap = new Map(
byName.filter((u) => u.name).map((u) => [u.name!.toLowerCase(), u])
);
const result = new Map<string, ResolvedCollaborator>();
for (const c of unique) {
if (isEmail(c)) {
result.set(c, { raw: c, matchedUser: emailMap.get(c.toLowerCase()) ?? null });
} else {
const match = nameMap.get(c.toLowerCase()) ?? null;
result.set(c, { raw: c, matchedUser: match });
}
}
return result;
}
// Resolve collaborator string to user - try email first, then name
export async function resolveCollaborator(collaborator: string): Promise<ResolvedCollaborator> {
const trimmed = collaborator.trim();

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import { createSessionPermissionChecks } from '@/services/session-permissions';
import { createShareAndEventHandlers } from '@/services/session-share-events';
@@ -21,7 +21,7 @@ const motivatorInclude = {
// ============================================
export async function getMotivatorSessionsByUserId(userId: string) {
return mergeSessionsByUserId(
const sessions = await mergeSessionsByUserId(
(uid) =>
prisma.movingMotivatorsSession.findMany({
where: { userId: uid },
@@ -33,14 +33,18 @@ export async function getMotivatorSessionsByUserId(userId: string) {
where: { userId: uid },
include: { session: { include: motivatorInclude } },
}),
userId,
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant));
return sessions.map((s) => ({
...s,
resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null },
}));
}
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
return fetchTeamCollaboratorSessions(
const sessions = await fetchTeamCollaboratorSessions(
(teamMemberIds, uid) =>
prisma.movingMotivatorsSession.findMany({
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
@@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
orderBy: { updatedAt: 'desc' },
}),
getTeamMemberIdsForAdminTeams,
userId,
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant));
return sessions.map((s) => ({
...s,
resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null },
}));
}
const motivatorByIdInclude = {

View File

@@ -131,14 +131,21 @@ export function createShareAndEventHandlers<TEventType extends string>(
type: TEventType,
payload: Record<string, unknown>
): Promise<SessionEventWithUser> {
return eventModel.create({
const event = await eventModel.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
}) as Promise<SessionEventWithUser>;
});
// Fire-and-forget: purge old events without blocking the response
eventModel.deleteMany({ where: { createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } } }).catch((err: unknown) => {
console.error('[cleanupOldEvents] Failed to purge old events:', err);
});
return event as SessionEventWithUser;
},
async getEvents(sessionId: string, since?: Date): Promise<SessionEventWithUser[]> {

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import { createSessionPermissionChecks } from '@/services/session-permissions';
import { createShareAndEventHandlers } from '@/services/session-share-events';
@@ -21,7 +21,7 @@ const sessionInclude = {
// ============================================
export async function getSessionsByUserId(userId: string) {
return mergeSessionsByUserId(
const sessions = await mergeSessionsByUserId(
(uid) =>
prisma.session.findMany({
where: { userId: uid },
@@ -33,14 +33,18 @@ export async function getSessionsByUserId(userId: string) {
where: { userId: uid },
include: { session: { include: sessionInclude } },
}),
userId,
(s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.collaborator));
return sessions.map((s) => ({
...s,
resolvedCollaborator: resolved.get(s.collaborator.trim()) ?? { raw: s.collaborator, matchedUser: null },
}));
}
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
return fetchTeamCollaboratorSessions(
const sessions = await fetchTeamCollaboratorSessions(
(teamMemberIds, uid) =>
prisma.session.findMany({
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
@@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
orderBy: { updatedAt: 'desc' },
}),
getTeamMemberIdsForAdminTeams,
userId,
(s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.collaborator));
return sessions.map((s) => ({
...s,
resolvedCollaborator: resolved.get(s.collaborator.trim()) ?? { raw: s.collaborator, matchedUser: null },
}));
}
const sessionByIdInclude = {

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import { createSessionPermissionChecks } from '@/services/session-permissions';
import { createShareAndEventHandlers } from '@/services/session-share-events';
@@ -21,7 +21,7 @@ const weeklyCheckInInclude = {
// ============================================
export async function getWeeklyCheckInSessionsByUserId(userId: string) {
return mergeSessionsByUserId(
const sessions = await mergeSessionsByUserId(
(uid) =>
prisma.weeklyCheckInSession.findMany({
where: { userId: uid },
@@ -33,14 +33,18 @@ export async function getWeeklyCheckInSessionsByUserId(userId: string) {
where: { userId: uid },
include: { session: { include: weeklyCheckInInclude } },
}),
userId,
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant));
return sessions.map((s) => ({
...s,
resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null },
}));
}
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
return fetchTeamCollaboratorSessions(
const sessions = await fetchTeamCollaboratorSessions(
(teamMemberIds, uid) =>
prisma.weeklyCheckInSession.findMany({
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
@@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
orderBy: { updatedAt: 'desc' },
}),
getTeamMemberIdsForAdminTeams,
userId,
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant));
return sessions.map((s) => ({
...s,
resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null },
}));
}
const weeklyCheckInByIdInclude = {

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import { createSessionPermissionChecks } from '@/services/session-permissions';
import { createShareAndEventHandlers } from '@/services/session-share-events';
@@ -21,7 +21,7 @@ const yearReviewInclude = {
// ============================================
export async function getYearReviewSessionsByUserId(userId: string) {
return mergeSessionsByUserId(
const sessions = await mergeSessionsByUserId(
(uid) =>
prisma.yearReviewSession.findMany({
where: { userId: uid },
@@ -33,14 +33,18 @@ export async function getYearReviewSessionsByUserId(userId: string) {
where: { userId: uid },
include: { session: { include: yearReviewInclude } },
}),
userId,
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant));
return sessions.map((s) => ({
...s,
resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null },
}));
}
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
return fetchTeamCollaboratorSessions(
const sessions = await fetchTeamCollaboratorSessions(
(teamMemberIds, uid) =>
prisma.yearReviewSession.findMany({
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
@@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
orderBy: { updatedAt: 'desc' },
}),
getTeamMemberIdsForAdminTeams,
userId,
(s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r }))
userId
);
const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant));
return sessions.map((s) => ({
...s,
resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null },
}));
}
const yearReviewByIdInclude = {