From 2ab7121e42b8fd57d9303324015406f276434912 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 13 Mar 2026 00:12:33 +0800 Subject: [PATCH] Phase 4: Frontend performance optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/App.tsx | 47 +++--- .../src/components/calendar/CalendarPage.tsx | 21 ++- .../components/dashboard/DashboardPage.tsx | 135 ++++++++++-------- frontend/src/hooks/useCalendars.ts | 14 +- frontend/vite.config.ts | 19 +++ 5 files changed, 147 insertions(+), 89 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1bb5810..17cc135 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => ( +
Loading...
+); + function ProtectedRoute({ children }: { children: React.ReactNode }) { const { authStatus, isLoading } = useAuth(); @@ -57,21 +62,21 @@ function App() { } > } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> - Loading...}> + }> diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 6dd9449..c1754ba 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -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('/events'); + const params: Record = {}; + if (visibleRange) { + params.start = visibleRange.start; + params.end = visibleRange.end; + } + const { data } = await api.get('/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(); diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 7aa0f44..f9caeb1 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -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; + let timeoutId: ReturnType; + + 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 ( +
+

+ {format(now, 'h:mm a')} + | + {format(now, 'EEEE, MMMM d, yyyy')} +

+ {updatedAgo && ( + <> + · + Updated {updatedAgo} + + + )} +
+ ); +}); + 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(null); - const [clockNow, setClockNow] = useState(() => new Date()); - - // Live clock — synced to the minute boundary, re-syncs after tab sleep/resume - useEffect(() => { - let intervalId: ReturnType; - let timeoutId: ReturnType; - - 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 (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 (
{/* Header — greeting + date + quick add */} @@ -208,27 +237,7 @@ export default function DashboardPage() {

{getGreeting(settings?.preferred_name || undefined)}

-
-

- {format(clockNow, 'h:mm a')} - | - {format(clockNow, 'EEEE, MMMM d, yyyy')} -

- {updatedAgo && ( - <> - · - Updated {updatedAgo} - - - )} -
+