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:
32
src/app/sessions/loading.tsx
Normal file
32
src/app/sessions/loading.tsx
Normal 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
42
src/app/users/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user