Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="time">` for familiar, intuitive UX
Expand Down
144 changes: 78 additions & 66 deletions src/components/ArchiveItem.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand All @@ -31,14 +37,31 @@ export const ArchiveItem: React.FC<ArchiveItemProps> = ({ 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 (
Expand Down Expand Up @@ -110,80 +133,71 @@ export const ArchiveItem: React.FC<ArchiveItemProps> = ({ day, onEdit }) => {
<span className="text-blue-600 print:text-black font-medium">
Total{' '}
<span className="hidden md:d-inline-flex">Hours: </span>
{getHoursWorkedForDay(day).toFixed(2)}h
{dayStats.hoursWorked.toFixed(2)}h
</span>
<span className="text-green-600 print:text-black font-medium">
Billable: {getBillableHoursForDay(day).toFixed(2)}h
Billable: {dayStats.billableHours.toFixed(2)}h
</span>
<span className="text-gray-600 print:text-black font-medium">
Non-billable: {getNonBillableHoursForDay(day).toFixed(2)}h
Non-billable: {dayStats.nonBillableHours.toFixed(2)}h
</span>
</div>
{getRevenueForDay(day) > 0 && (
{dayStats.revenue > 0 && (
<span className="text-green-600 print:text-black font-semibold">
Revenue: ${getRevenueForDay(day).toFixed(2)}
Revenue: ${dayStats.revenue.toFixed(2)}
</span>
)}
</div>
</div>
</div>

{/* Daily Summary */}
{(() => {
const descriptions = day.tasks
.filter(task => task.description)
.map(task => task.description!);
const summary = generateDailySummary(descriptions);

if (!summary) return null;

return (
<div className="space-y-2 border-t pt-4">
<h4 className="font-medium text-gray-900 flex items-center mb-2">
<FileText className="w-4 h-4 mr-2" />
Overview
</h4>
<Tabs defaultValue="summary" className="w-full">
<TabsList className="border-b mb-4">
<TabsTrigger
value="summary"
className="px-3 py-1 text-sm font-medium text-gray-700 data-[state=active]:border-blue-600 data-[state=active]:border-b-2 focus:outline-none"
>
Summary
</TabsTrigger>
<TabsTrigger
value="notes"
className="px-3 py-1 text-sm font-medium text-gray-700 data-[state=active]:border-blue-600 data-[state=active]:border-b-2 focus:outline-none"
>
Notes
</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="focus:outline-none">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-md print:bg-white print:border print:border-gray-300">
{dailySummary && (
<div className="space-y-2 border-t pt-4">
<h4 className="font-medium text-gray-900 flex items-center mb-2">
<FileText className="w-4 h-4 mr-2" />
Overview
</h4>
<Tabs defaultValue="summary" className="w-full">
<TabsList className="border-b mb-4">
<TabsTrigger
value="summary"
className="px-3 py-1 text-sm font-medium text-gray-700 data-[state=active]:border-blue-600 data-[state=active]:border-b-2 focus:outline-none"
>
Summary
</TabsTrigger>
<TabsTrigger
value="notes"
className="px-3 py-1 text-sm font-medium text-gray-700 data-[state=active]:border-blue-600 data-[state=active]:border-b-2 focus:outline-none"
>
Notes
</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="focus:outline-none">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-md print:bg-white print:border print:border-gray-300">
<MarkdownDisplay
content={dailySummary}
className="prose-p:text-gray-700 dark:prose-p:text-gray-300 print:prose-p:text-gray-800"
/>
</div>
</TabsContent>
<TabsContent value="notes" className="focus:outline-none">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-md print:bg-white print:border print:border-gray-300">
{day.notes ? (
<MarkdownDisplay
content={summary}
content={day.notes}
className="prose-p:text-gray-700 dark:prose-p:text-gray-300 print:prose-p:text-gray-800"
/>
</div>
</TabsContent>
<TabsContent value="notes" className="focus:outline-none">
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-md print:bg-white print:border print:border-gray-300">
{day.notes ? (
<MarkdownDisplay
content={day.notes}
className="prose-p:text-gray-700 dark:prose-p:text-gray-300 print:prose-p:text-gray-800"
/>
) : (
<div className="prose-sm prose-p:leading-relaxed prose-p:my-1 prose-p:text-gray-700 dark:prose-p:text-gray-300 print:prose-p:text-gray-800">
<p>No notes for this day have been entered.</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
})()}
) : (
<div className="prose-sm prose-p:leading-relaxed prose-p:my-1 prose-p:text-gray-700 dark:prose-p:text-gray-300 print:prose-p:text-gray-800">
<p>No notes for this day have been entered.</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
)}

{/* Tasks Table */}
<div className="print:mt-2">
Expand Down Expand Up @@ -215,10 +229,8 @@ export const ArchiveItem: React.FC<ArchiveItemProps> = ({ day, onEdit }) => {
</TableHeader>
<TableBody>
{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;
Expand Down
35 changes: 14 additions & 21 deletions src/pages/Archive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,10 +27,6 @@ import SiteNavigationMenu from '@/components/Navigation';
const ArchiveContent: React.FC = () => {
const {
archivedDays,
getHoursWorkedForDay,
getBillableHoursForDay,
getNonBillableHoursForDay,
getRevenueForDay,
projects,
categories
} = useTimeTracking();
Expand Down Expand Up @@ -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);
Expand Down
Loading