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 LockScreen from '@/components/auth/LockScreen';
|
||||
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 RouteFallback = () => (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
|
||||
);
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authStatus, isLoading } = useAuth();
|
||||
|
||||
@ -57,21 +62,21 @@ function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="todos" element={<TodosPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="reminders" element={<RemindersPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="people" element={<PeoplePage />} />
|
||||
<Route path="locations" element={<LocationsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
|
||||
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
|
||||
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
|
||||
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
|
||||
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
|
||||
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
|
||||
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
|
||||
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
|
||||
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
|
||||
<Route
|
||||
path="admin/*"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<AdminPortal />
|
||||
</Suspense>
|
||||
</AdminRoute>
|
||||
|
||||
@ -205,13 +205,22 @@ export default function CalendarPage() {
|
||||
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({
|
||||
queryKey: ['calendar-events'],
|
||||
queryKey: ['calendar-events', visibleRange?.start, visibleRange?.end],
|
||||
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;
|
||||
},
|
||||
refetchInterval: 5_000,
|
||||
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const selectedEvent = useMemo(
|
||||
@ -467,6 +476,12 @@ export default function CalendarPage() {
|
||||
const handleDatesSet = (arg: DatesSetArg) => {
|
||||
setCalendarTitle(arg.view.title);
|
||||
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();
|
||||
|
||||
@ -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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
@ -34,6 +34,75 @@ function getGreeting(name?: string): string {
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@ -42,38 +111,7 @@ export default function DashboardPage() {
|
||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [clockNow, setClockNow] = useState(() => new Date());
|
||||
|
||||
// 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);
|
||||
};
|
||||
}, []);
|
||||
// Clock state moved to <ClockDisplay /> (AS-3)
|
||||
|
||||
// Click outside to close dropdown
|
||||
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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 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">
|
||||
{getGreeting(settings?.preferred_name || undefined)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<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>
|
||||
<ClockDisplay dataUpdatedAt={dataUpdatedAt} onRefresh={handleRefresh} />
|
||||
</div>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
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({
|
||||
queryKey: ['calendars', 'shared'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||
return data;
|
||||
},
|
||||
refetchInterval: pollingEnabled ? 5_000 : false,
|
||||
refetchInterval: pollingEnabled && hasSharingRef.current ? 5_000 : false,
|
||||
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 owned = (ownedQuery.data ?? []).map((c) => c.id);
|
||||
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
||||
|
||||
@ -9,6 +9,25 @@ export default defineConfig({
|
||||
'@': 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: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user