From faaf4480da225d37951ddd3970cb970debd32bec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 15:33:32 +0000 Subject: [PATCH 1/2] perf: memoize expensive archive calculations Archive.tsx: - Import calculation utilities directly at module scope (stable refs) instead of pulling unstable context method wrappers - Wrap all four summary-stat reduce() calls (totalHoursWorked, totalBillable, totalNonBillable, totalRevenue) in a single useMemo keyed on [filteredDays, projects, categories]; previously they ran unconditionally on every render even when nothing had changed ArchiveItem.tsx: - Add dayStats useMemo ([day, projects, categories]) computing per-day hours and revenue once per item instead of calling context getters inline, which also eliminates the double getRevenueForDay() call in the revenue badge - Add projectMap / categoryMap useMemo lookups so the task-table row loop uses O(1) Map.get() instead of O(n) Array.find() per row - Convert the daily-summary IIFE into a useMemo([day.tasks]) so generateDailySummary() only runs when task descriptions actually change https://claude.ai/code/session_012AmNjF3Ju9VJwSWybJqBEw --- src/components/ArchiveItem.tsx | 144 ++++++++++++++++++--------------- src/pages/Archive.tsx | 35 ++++---- 2 files changed, 92 insertions(+), 87 deletions(-) diff --git a/src/components/ArchiveItem.tsx b/src/components/ArchiveItem.tsx index f02d07c..16e1cb6 100644 --- a/src/components/ArchiveItem.tsx +++ b/src/components/ArchiveItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -20,6 +20,12 @@ import { import { MarkdownDisplay } from '@/components/MarkdownDisplay'; import { DayRecord } from '@/contexts/TimeTrackingContext'; import { useTimeTracking } from '@/hooks/useTimeTracking'; +import { + getHoursWorkedForDay as calcHoursWorked, + getBillableHoursForDay as calcBillableHours, + getNonBillableHoursForDay as calcNonBillableHours, + getRevenueForDay as calcRevenue +} from '@/utils/calculationUtils'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'; interface ArchiveItemProps { @@ -31,14 +37,31 @@ export const ArchiveItem: React.FC = ({ day, onEdit }) => { const { restoreArchivedDay, isDayStarted, - getHoursWorkedForDay, - getRevenueForDay, - getBillableHoursForDay, - getNonBillableHoursForDay, projects, categories } = useTimeTracking(); + // Memoize per-day stats so they only recompute when the day data, + // project rates, or category billing settings change. + const dayStats = useMemo(() => ({ + hoursWorked: calcHoursWorked(day), + billableHours: calcBillableHours(day, projects, categories), + nonBillableHours: calcNonBillableHours(day, projects, categories), + revenue: calcRevenue(day, projects, categories) + }), [day, projects, categories]); + + // Build lookup maps once so the task table doesn't do O(n) searches per row. + const projectMap = useMemo(() => new Map(projects.map(p => [p.name, p])), [projects]); + const categoryMap = useMemo(() => new Map(categories.map(c => [c.name, c])), [categories]); + + // Generate daily summary only when task descriptions change. + const dailySummary = useMemo(() => { + const descriptions = day.tasks + .filter(task => task.description) + .map(task => task.description!); + return generateDailySummary(descriptions); + }, [day.tasks]); + const handleRestore = () => { if (isDayStarted) { if ( @@ -110,18 +133,18 @@ export const ArchiveItem: React.FC = ({ day, onEdit }) => { Total{' '} Hours: - {getHoursWorkedForDay(day).toFixed(2)}h + {dayStats.hoursWorked.toFixed(2)}h - Billable: {getBillableHoursForDay(day).toFixed(2)}h + Billable: {dayStats.billableHours.toFixed(2)}h - Non-billable: {getNonBillableHoursForDay(day).toFixed(2)}h + Non-billable: {dayStats.nonBillableHours.toFixed(2)}h - {getRevenueForDay(day) > 0 && ( + {dayStats.revenue > 0 && ( - Revenue: ${getRevenueForDay(day).toFixed(2)} + Revenue: ${dayStats.revenue.toFixed(2)} )} @@ -129,61 +152,52 @@ export const ArchiveItem: React.FC = ({ day, onEdit }) => { {/* Daily Summary */} - {(() => { - const descriptions = day.tasks - .filter(task => task.description) - .map(task => task.description!); - const summary = generateDailySummary(descriptions); - - if (!summary) return null; - - return ( -
-

- - Overview -

- - - - Summary - - - Notes - - - -
+ {dailySummary && ( +
+

+ + Overview +

+ + + + Summary + + + Notes + + + +
+ +
+
+ +
+ {day.notes ? ( -
-
- -
- {day.notes ? ( - - ) : ( -
-

No notes for this day have been entered.

-
- )} -
-
-
-
- ); - })()} + ) : ( +
+

No notes for this day have been entered.

+
+ )} +
+
+
+
+ )} {/* Tasks Table */}
@@ -215,10 +229,8 @@ export const ArchiveItem: React.FC = ({ day, onEdit }) => { {day.tasks.map(task => { - const project = projects.find(p => p.name === task.project); - const category = categories.find( - c => c.name === task.category - ); + const project = projectMap.get(task.project ?? ''); + const category = categoryMap.get(task.category ?? ''); const taskHours = task.duration ? task.duration / (1000 * 60 * 60) : 0; diff --git a/src/pages/Archive.tsx b/src/pages/Archive.tsx index a98472c..9acebec 100644 --- a/src/pages/Archive.tsx +++ b/src/pages/Archive.tsx @@ -4,6 +4,12 @@ import { DayRecord } from '@/contexts/TimeTrackingContext'; import { useTimeTracking } from '@/hooks/useTimeTracking'; +import { + getHoursWorkedForDay as calcHoursWorked, + getBillableHoursForDay as calcBillableHours, + getNonBillableHoursForDay as calcNonBillableHours, + getRevenueForDay as calcRevenue +} from '@/utils/calculationUtils'; import { ArchiveItem } from '@/components/ArchiveItem'; import { ArchiveEditDialog } from '@/components/ArchiveEditDialog'; import { ExportDialog } from '@/components/ExportDialog'; @@ -21,10 +27,6 @@ import SiteNavigationMenu from '@/components/Navigation'; const ArchiveContent: React.FC = () => { const { archivedDays, - getHoursWorkedForDay, - getBillableHoursForDay, - getNonBillableHoursForDay, - getRevenueForDay, projects, categories } = useTimeTracking(); @@ -83,23 +85,14 @@ const ArchiveContent: React.FC = () => { ); }, [archivedDays, filters]); - // Calculate summary stats based on filtered days - const totalHoursWorked = filteredDays.reduce( - (sum, day) => sum + getHoursWorkedForDay(day), - 0 - ); - const totalBillableHours = filteredDays.reduce( - (sum, day) => sum + getBillableHoursForDay(day), - 0 - ); - const totalNonBillableHours = filteredDays.reduce( - (sum, day) => sum + getNonBillableHoursForDay(day), - 0 - ); - const totalRevenue = filteredDays.reduce( - (sum, day) => sum + getRevenueForDay(day), - 0 - ); + // Calculate summary stats based on filtered days — memoized so they only + // recompute when filteredDays, projects, or categories actually change. + const { totalHoursWorked, totalBillableHours, totalNonBillableHours, totalRevenue } = useMemo(() => ({ + totalHoursWorked: filteredDays.reduce((sum, day) => sum + calcHoursWorked(day), 0), + totalBillableHours: filteredDays.reduce((sum, day) => sum + calcBillableHours(day, projects, categories), 0), + totalNonBillableHours: filteredDays.reduce((sum, day) => sum + calcNonBillableHours(day, projects, categories), 0), + totalRevenue: filteredDays.reduce((sum, day) => sum + calcRevenue(day, projects, categories), 0) + }), [filteredDays, projects, categories]); const handleEdit = (day: DayRecord) => { setEditingDay(day); From 42743bfad15137faa786f52f54d25ada61f475d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 15:33:49 +0000 Subject: [PATCH 2/2] docs: update CHANGELOG with performance improvement entries https://claude.ai/code/session_012AmNjF3Ju9VJwSWybJqBEw --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2345ca..6b719f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Performance +- **Archive page summary stats** (`src/pages/Archive.tsx`): Wrapped the four summary-stat `reduce()` calls (total hours, billable hours, non-billable hours, revenue) in a single `useMemo` keyed on `[filteredDays, projects, categories]`; previously they recomputed on every render regardless of whether the underlying data had changed. Also replaced unstable context method wrappers with stable module-level imports from `calculationUtils` so the memo invalidates only when data actually changes. +- **Archive item row rendering** (`src/components/ArchiveItem.tsx`): Per-day stats (`hoursWorked`, `billableHours`, `nonBillableHours`, `revenue`) are now computed in a `useMemo([day, projects, categories])` instead of inline on every render; also eliminates the duplicate `getRevenueForDay()` call that previously appeared twice in the revenue badge. Project and category lookups in the task table now use `Map.get()` (O(1)) instead of `Array.find()` (O(n)) per row via `useMemo`-cached lookup maps. The daily-summary `generateDailySummary()` call is memoized on `day.tasks` so it only runs when task descriptions change. + ### Added - Native HTML5 time picker component (`TimePicker`) following web standards and a11y best practices - Uses `` for familiar, intuitive UX