Phase 4: Frontend performance optimizations
- AW-2: Scope calendar events fetch to visible date range via start/end query params, leveraging existing backend support - AW-3: Reduce calendar events poll from 5s to 30s (personal organiser doesn't need 12 API calls/min) - AS-4: Gate shared-calendar polling on hasSharedCalendars — saves 12 wasted API calls/min for personal-only users - AS-2: Lazy-load all route components with React.lazy() — only AdminPortal was previously lazy, now all 10 routes are code-split - AS-1: Add Vite manualChunks to split FullCalendar (~400KB), React, TanStack Query, and UI libs into separate cacheable chunks - AS-3: Extract clockNow into isolated ClockDisplay memo component — prevents all 8 dashboard widgets from re-rendering every minute Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
846019d5c1
commit
2ab7121e42
@ -3,19 +3,24 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import LockScreen from '@/components/auth/LockScreen';
|
import LockScreen from '@/components/auth/LockScreen';
|
||||||
import AppLayout from '@/components/layout/AppLayout';
|
import AppLayout from '@/components/layout/AppLayout';
|
||||||
import DashboardPage from '@/components/dashboard/DashboardPage';
|
|
||||||
import TodosPage from '@/components/todos/TodosPage';
|
|
||||||
import CalendarPage from '@/components/calendar/CalendarPage';
|
|
||||||
import RemindersPage from '@/components/reminders/RemindersPage';
|
|
||||||
import ProjectsPage from '@/components/projects/ProjectsPage';
|
|
||||||
import ProjectDetail from '@/components/projects/ProjectDetail';
|
|
||||||
import PeoplePage from '@/components/people/PeoplePage';
|
|
||||||
import LocationsPage from '@/components/locations/LocationsPage';
|
|
||||||
import SettingsPage from '@/components/settings/SettingsPage';
|
|
||||||
import NotificationsPage from '@/components/notifications/NotificationsPage';
|
|
||||||
|
|
||||||
|
// AS-2: Lazy-load all route components to reduce initial bundle parse time
|
||||||
|
const DashboardPage = lazy(() => import('@/components/dashboard/DashboardPage'));
|
||||||
|
const TodosPage = lazy(() => import('@/components/todos/TodosPage'));
|
||||||
|
const CalendarPage = lazy(() => import('@/components/calendar/CalendarPage'));
|
||||||
|
const RemindersPage = lazy(() => import('@/components/reminders/RemindersPage'));
|
||||||
|
const ProjectsPage = lazy(() => import('@/components/projects/ProjectsPage'));
|
||||||
|
const ProjectDetail = lazy(() => import('@/components/projects/ProjectDetail'));
|
||||||
|
const PeoplePage = lazy(() => import('@/components/people/PeoplePage'));
|
||||||
|
const LocationsPage = lazy(() => import('@/components/locations/LocationsPage'));
|
||||||
|
const SettingsPage = lazy(() => import('@/components/settings/SettingsPage'));
|
||||||
|
const NotificationsPage = lazy(() => import('@/components/notifications/NotificationsPage'));
|
||||||
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
||||||
|
|
||||||
|
const RouteFallback = () => (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
|
||||||
|
);
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { authStatus, isLoading } = useAuth();
|
const { authStatus, isLoading } = useAuth();
|
||||||
|
|
||||||
@ -57,21 +62,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
|
||||||
<Route path="todos" element={<TodosPage />} />
|
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
|
||||||
<Route path="calendar" element={<CalendarPage />} />
|
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
|
||||||
<Route path="reminders" element={<RemindersPage />} />
|
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
|
||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
|
||||||
<Route path="people" element={<PeoplePage />} />
|
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
|
||||||
<Route path="locations" element={<LocationsPage />} />
|
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
|
||||||
<Route
|
<Route
|
||||||
path="admin/*"
|
path="admin/*"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<AdminPortal />
|
<AdminPortal />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
|
|||||||
@ -205,13 +205,22 @@ export default function CalendarPage() {
|
|||||||
return () => el.removeEventListener('wheel', handleWheel);
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// AW-2: Track visible date range for scoped event fetching
|
||||||
|
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null);
|
||||||
|
|
||||||
const { data: events = [] } = useQuery({
|
const { data: events = [] } = useQuery({
|
||||||
queryKey: ['calendar-events'],
|
queryKey: ['calendar-events', visibleRange?.start, visibleRange?.end],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<CalendarEvent[]>('/events');
|
const params: Record<string, string> = {};
|
||||||
|
if (visibleRange) {
|
||||||
|
params.start = visibleRange.start;
|
||||||
|
params.end = visibleRange.end;
|
||||||
|
}
|
||||||
|
const { data } = await api.get<CalendarEvent[]>('/events', { params });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: 5_000,
|
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedEvent = useMemo(
|
const selectedEvent = useMemo(
|
||||||
@ -467,6 +476,12 @@ export default function CalendarPage() {
|
|||||||
const handleDatesSet = (arg: DatesSetArg) => {
|
const handleDatesSet = (arg: DatesSetArg) => {
|
||||||
setCalendarTitle(arg.view.title);
|
setCalendarTitle(arg.view.title);
|
||||||
setCurrentView(arg.view.type as CalendarView);
|
setCurrentView(arg.view.type as CalendarView);
|
||||||
|
// AW-2: Capture visible range for scoped event fetching
|
||||||
|
const start = arg.start.toISOString().split('T')[0];
|
||||||
|
const end = arg.end.toISOString().split('T')[0];
|
||||||
|
setVisibleRange((prev) =>
|
||||||
|
prev?.start === start && prev?.end === end ? prev : { start, end }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -34,6 +34,75 @@ function getGreeting(name?: string): string {
|
|||||||
return `Good night${suffix}`;
|
return `Good night${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AS-3: Isolated clock component — only this re-renders every minute,
|
||||||
|
// not all 8 dashboard widgets.
|
||||||
|
const ClockDisplay = memo(function ClockDisplay({ dataUpdatedAt, onRefresh }: {
|
||||||
|
dataUpdatedAt?: number;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: ReturnType<typeof setInterval>;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function startClock() {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setNow(new Date());
|
||||||
|
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
intervalId = setInterval(() => setNow(new Date()), 60_000);
|
||||||
|
}, msUntilNextMinute);
|
||||||
|
}
|
||||||
|
|
||||||
|
startClock();
|
||||||
|
function handleVisibility() {
|
||||||
|
if (document.visibilityState === 'visible') startClock();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatedAgo = dataUpdatedAt
|
||||||
|
? (() => {
|
||||||
|
const mins = Math.floor((now.getTime() - dataUpdatedAt) / 60_000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins === 1) return '1 min ago';
|
||||||
|
return `${mins} min ago`;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<span className="tabular-nums">{format(now, 'h:mm a')}</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
||||||
|
{format(now, 'EEEE, MMMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
{updatedAgo && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40 text-xs">·</span>
|
||||||
|
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
||||||
|
title="Refresh dashboard"
|
||||||
|
aria-label="Refresh dashboard"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -42,38 +111,7 @@ export default function DashboardPage() {
|
|||||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [clockNow, setClockNow] = useState(() => new Date());
|
// Clock state moved to <ClockDisplay /> (AS-3)
|
||||||
|
|
||||||
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId: ReturnType<typeof setInterval>;
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
function startClock() {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setClockNow(new Date());
|
|
||||||
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
setClockNow(new Date());
|
|
||||||
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
|
|
||||||
}, msUntilNextMinute);
|
|
||||||
}
|
|
||||||
|
|
||||||
startClock();
|
|
||||||
|
|
||||||
// Re-sync when tab becomes visible again (after sleep/background throttle)
|
|
||||||
function handleVisibility() {
|
|
||||||
if (document.visibilityState === 'visible') startClock();
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', handleVisibility);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Click outside to close dropdown
|
// Click outside to close dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -191,15 +229,6 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAgo = dataUpdatedAt
|
|
||||||
? (() => {
|
|
||||||
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
|
|
||||||
if (mins < 1) return 'just now';
|
|
||||||
if (mins === 1) return '1 min ago';
|
|
||||||
return `${mins} min ago`;
|
|
||||||
})()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header — greeting + date + quick add */}
|
{/* Header — greeting + date + quick add */}
|
||||||
@ -208,27 +237,7 @@ export default function DashboardPage() {
|
|||||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||||
{getGreeting(settings?.preferred_name || undefined)}
|
{getGreeting(settings?.preferred_name || undefined)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<ClockDisplay dataUpdatedAt={dataUpdatedAt} onRefresh={handleRefresh} />
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
|
|
||||||
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
|
||||||
{format(clockNow, 'EEEE, MMMM d, yyyy')}
|
|
||||||
</p>
|
|
||||||
{updatedAgo && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground/40 text-xs">·</span>
|
|
||||||
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
|
||||||
title="Refresh dashboard"
|
|
||||||
aria-label="Refresh dashboard"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||||
@ -16,16 +16,26 @@ export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AS-4: Gate shared-calendar polling on whether user participates in sharing.
|
||||||
|
// Saves ~12 API calls/min for personal-only users.
|
||||||
|
// Use a ref to latch "has sharing" once discovered, so polling doesn't flicker.
|
||||||
|
const hasSharingRef = useRef(false);
|
||||||
|
const ownsShared = (ownedQuery.data ?? []).some((c) => c.is_shared);
|
||||||
|
if (ownsShared) hasSharingRef.current = true;
|
||||||
|
|
||||||
const sharedQuery = useQuery({
|
const sharedQuery = useQuery({
|
||||||
queryKey: ['calendars', 'shared'],
|
queryKey: ['calendars', 'shared'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: pollingEnabled ? 5_000 : false,
|
refetchInterval: pollingEnabled && hasSharingRef.current ? 5_000 : false,
|
||||||
staleTime: 3_000,
|
staleTime: 3_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also latch if user is a member of others' shared calendars
|
||||||
|
if ((sharedQuery.data ?? []).length > 0) hasSharingRef.current = true;
|
||||||
|
|
||||||
const allCalendarIds = useMemo(() => {
|
const allCalendarIds = useMemo(() => {
|
||||||
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
||||||
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
||||||
|
|||||||
@ -9,6 +9,25 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// AS-1: Split large dependencies into separate chunks for better caching
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'vendor-query': ['@tanstack/react-query'],
|
||||||
|
'vendor-fullcalendar': [
|
||||||
|
'@fullcalendar/react',
|
||||||
|
'@fullcalendar/core',
|
||||||
|
'@fullcalendar/daygrid',
|
||||||
|
'@fullcalendar/timegrid',
|
||||||
|
'@fullcalendar/interaction',
|
||||||
|
],
|
||||||
|
'vendor-ui': ['sonner', 'lucide-react', 'date-fns'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user