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:
Kyle 2026-03-13 00:12:33 +08:00
parent 846019d5c1
commit 2ab7121e42
5 changed files with 147 additions and 89 deletions

View File

@ -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>

View File

@ -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();

View File

@ -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

View File

@ -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);

View File

@ -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: {