Optimize frontend computations

Address performance issues on the home page by optimizing frontend calculations. The data loading is efficient, indicating that the bottleneck is likely in the client-side processing of the 1MB dataset.
This commit is contained in:
gpt-engineer-app[bot]
2025-07-28 19:08:50 +00:00
committed by PhatPhuckDave
parent 7eb957f66a
commit b1e08815b3
7 changed files with 329 additions and 9 deletions

View File

@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Factory, TrendingUp, Briefcase, BarChart3 } from 'lucide-react'; import { Factory, TrendingUp, Briefcase, BarChart3 } from 'lucide-react';
import { formatISK } from '@/utils/priceUtils'; import { formatISK } from '@/utils/priceUtils';
import RecapPopover from './RecapPopover'; import OptimizedRecapPopover from './OptimizedRecapPopover';
import { IndJob } from '@/lib/types'; import { IndJob } from '@/lib/types';
interface DashboardStatsProps { interface DashboardStatsProps {
@@ -57,7 +57,7 @@ const DashboardStats = ({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RecapPopover <OptimizedRecapPopover
title="Revenue Breakdown" title="Revenue Breakdown"
jobs={jobs} jobs={jobs}
calculateJobValue={calculateJobRevenue} calculateJobValue={calculateJobRevenue}
@@ -65,7 +65,7 @@ const DashboardStats = ({
<div className="text-2xl font-bold text-green-400 cursor-pointer hover:text-green-300 transition-colors"> <div className="text-2xl font-bold text-green-400 cursor-pointer hover:text-green-300 transition-colors">
{formatISK(totalRevenue)} {formatISK(totalRevenue)}
</div> </div>
</RecapPopover> </OptimizedRecapPopover>
</CardContent> </CardContent>
</Card> </Card>
@@ -85,7 +85,7 @@ const DashboardStats = ({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RecapPopover <OptimizedRecapPopover
title="Profit Breakdown" title="Profit Breakdown"
jobs={jobs} jobs={jobs}
calculateJobValue={calculateJobProfit} calculateJobValue={calculateJobProfit}
@@ -93,7 +93,7 @@ const DashboardStats = ({
<div className={`text-2xl font-bold cursor-pointer transition-colors ${totalProfit >= 0 ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'}`}> <div className={`text-2xl font-bold cursor-pointer transition-colors ${totalProfit >= 0 ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'}`}>
{formatISK(totalProfit)} {formatISK(totalProfit)}
</div> </div>
</RecapPopover> </OptimizedRecapPopover>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -0,0 +1,81 @@
import React, { useMemo } from 'react';
import { IndJob } from '@/lib/types';
import JobGroup from './JobGroup';
interface OptimizedJobsSectionProps {
regularJobs: IndJob[];
trackedJobs: IndJob[];
collapsedGroups: Record<string, boolean>;
loadingStatuses: Set<string>;
onToggleGroup: (status: string) => void;
onEdit: (job: IndJob) => void;
onDelete: (jobId: string) => void;
onUpdateProduced: (jobId: string, produced: number) => void;
onImportBOM: (jobId: string, items: { name: string; quantity: number }[]) => void;
}
const OptimizedJobsSection = React.memo(({
regularJobs,
trackedJobs,
collapsedGroups,
loadingStatuses,
onToggleGroup,
onEdit,
onDelete,
onUpdateProduced,
onImportBOM
}: OptimizedJobsSectionProps) => {
// Memoize expensive grouping operation
const jobGroups = useMemo(() => {
return regularJobs.reduce((groups, job) => {
const status = job.status;
if (!groups[status]) {
groups[status] = [];
}
groups[status].push(job);
return groups;
}, {} as Record<string, IndJob[]>);
}, [regularJobs]);
return (
<div className="space-y-4">
<div className="space-y-6">
{Object.entries(jobGroups).map(([status, statusJobs]) => (
<JobGroup
key={status}
status={status}
jobs={statusJobs}
isCollapsed={collapsedGroups[status] || false}
onToggle={onToggleGroup}
onEdit={onEdit}
onDelete={onDelete}
onUpdateProduced={onUpdateProduced}
onImportBOM={onImportBOM}
isLoading={loadingStatuses.has(status)}
/>
))}
</div>
{trackedJobs.length > 0 && (
<div className="space-y-4 mt-8 pt-8 border-t border-gray-700">
<JobGroup
status="Tracked"
jobs={trackedJobs}
isCollapsed={collapsedGroups['Tracked'] || false}
onToggle={onToggleGroup}
onEdit={onEdit}
onDelete={onDelete}
onUpdateProduced={onUpdateProduced}
onImportBOM={onImportBOM}
isTracked={true}
isLoading={loadingStatuses.has('Tracked')}
/>
</div>
)}
</div>
);
});
OptimizedJobsSection.displayName = 'OptimizedJobsSection';
export default OptimizedJobsSection;

View File

@@ -0,0 +1,117 @@
import React, { useState, useMemo } from 'react';
import { IndJob } from '@/lib/types';
import { formatISK } from '@/utils/priceUtils';
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { getStatusBackgroundColor } from '@/utils/jobStatusUtils';
interface OptimizedRecapPopoverProps {
title: string;
jobs: IndJob[];
children: React.ReactNode;
calculateJobValue: (job: IndJob) => number;
}
const OptimizedRecapPopover: React.FC<OptimizedRecapPopoverProps> = React.memo(({
title,
jobs,
children,
calculateJobValue
}) => {
const navigate = useNavigate();
const [sortDescending, setSortDescending] = useState(true);
// Memoize expensive calculations
const jobContributions = useMemo(() => {
const contributions = jobs
.map(job => ({
job,
value: calculateJobValue(job)
}))
.filter(({ value }) => value !== 0);
return contributions.sort((a, b) => {
if (sortDescending) {
if (a.value < 0 && b.value >= 0) return -1;
if (a.value >= 0 && b.value < 0) return 1;
return Math.abs(b.value) - Math.abs(a.value);
} else {
if (a.value >= 0 && b.value < 0) return -1;
if (a.value < 0 && b.value >= 0) return 1;
return Math.abs(a.value) - Math.abs(b.value);
}
});
}, [jobs, calculateJobValue, sortDescending]);
const handleJobClick = (jobId: string) => {
navigate(`/${jobId}`);
};
const toggleSort = (e: React.MouseEvent) => {
e.stopPropagation();
setSortDescending(!sortDescending);
};
const handleItemClick = (e: React.MouseEvent, jobId: string) => {
const target = e.target as HTMLElement;
const hasNoNavigate = target.closest('[data-no-navigate]');
if (!hasNoNavigate) {
handleJobClick(jobId);
}
};
return (
<Popover>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
<PopoverContent className="w-[30rem] bg-gray-800/95 border-gray-600 text-white max-h-[40rem] overflow-y-auto">
<CardHeader className="pb-3">
<CardTitle className="text-lg text-white flex items-center justify-between">
<span>{title}</span>
<button
onClick={toggleSort}
className="flex items-center gap-1 text-sm font-normal text-gray-300 hover:text-white transition-colors"
title="Click to toggle sort order"
data-no-navigate
>
Sort {sortDescending ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{jobContributions.length === 0 ? (
<p className="text-gray-400 text-sm">No contributions to display</p>
) : (
jobContributions.map(({ job, value }) => (
<div
key={job.id}
onClick={(e) => handleItemClick(e, job.id)}
className={`flex justify-between items-center p-2 rounded hover:bg-gray-700/50 cursor-pointer transition-colors border-l-2 border-l-gray-600 ${getStatusBackgroundColor(job.status)}`}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-blue-400 truncate" title={job.outputItem}>
{job.outputItem}
</div>
<div className="text-xs text-gray-400">
ID: {job.id}
</div>
</div>
<div className={`text-sm font-medium ml-2 ${value >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{formatISK(value)}
</div>
</div>
))
)}
</CardContent>
</PopoverContent>
</Popover>
);
});
OptimizedRecapPopover.displayName = 'OptimizedRecapPopover';
export default OptimizedRecapPopover;

View File

@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import { IndJob } from '@/lib/types';
// Memoized job metrics calculation with single-pass optimization
export const useOptimizedJobMetrics = (jobs: IndJob[]) => {
// Single-pass calculation with memoization on jobs array reference
const metrics = useMemo(() => {
const totalJobs = jobs.length;
let totalRevenue = 0;
let totalProfit = 0;
// Pre-compute job metrics in single pass
const jobMetricsMap = new Map<string, { revenue: number; profit: number; expenditure: number; income: number }>();
for (const job of jobs) {
const expenditure = job.expenditures?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
const income = job.income?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
const profit = income - expenditure;
totalRevenue += income;
totalProfit += profit;
jobMetricsMap.set(job.id, {
revenue: income,
profit,
expenditure,
income
});
}
// Create optimized calculation functions that use pre-computed values
const calculateJobRevenue = (job: IndJob) => {
return jobMetricsMap.get(job.id)?.revenue || 0;
};
const calculateJobProfit = (job: IndJob) => {
return jobMetricsMap.get(job.id)?.profit || 0;
};
return {
totalJobs,
totalRevenue,
totalProfit,
calculateJobRevenue,
calculateJobProfit,
jobMetricsMap
};
}, [jobs]);
return metrics;
};

View File

@@ -238,6 +238,16 @@ export type SigviewResponse<Texpand = unknown> = Required<SigviewRecord> & BaseS
export type SystemResponse<Texpand = unknown> = Required<SystemRecord> & BaseSystemFields<Texpand> export type SystemResponse<Texpand = unknown> = Required<SystemRecord> & BaseSystemFields<Texpand>
export type WormholeSystemsResponse<Texpand = unknown> = Required<WormholeSystemsRecord> & BaseSystemFields<Texpand> export type WormholeSystemsResponse<Texpand = unknown> = Required<WormholeSystemsRecord> & BaseSystemFields<Texpand>
// Facility types (mock for compatibility)
export type IndFacilityRecord = {
id: string;
name: string;
created?: IsoDateString;
updated?: IsoDateString;
}
export type IndFacilityResponse<Texpand = unknown> = Required<IndFacilityRecord> & BaseSystemFields<Texpand>
// Types containing all Records and Responses, useful for creating typing helper functions // Types containing all Records and Responses, useful for creating typing helper functions
export type CollectionRecords = { export type CollectionRecords = {

View File

@@ -6,10 +6,10 @@ import SearchOverlay from '@/components/SearchOverlay';
import TransactionChart from '@/components/TransactionChart'; import TransactionChart from '@/components/TransactionChart';
import DashboardStats from '@/components/DashboardStats'; import DashboardStats from '@/components/DashboardStats';
import JobsToolbar from '@/components/JobsToolbar'; import JobsToolbar from '@/components/JobsToolbar';
import JobsSection from '@/components/JobsSection'; import OptimizedJobsSection from '@/components/OptimizedJobsSection';
import { useDashboard } from '@/hooks/useDashboard'; import { useDashboard } from '@/hooks/useDashboard';
import { useDashboardHandlers } from '@/hooks/useDashboardHandlers'; import { useDashboardHandlers } from '@/hooks/useDashboardHandlers';
import { useJobMetrics } from '@/hooks/useJobMetrics'; import { useOptimizedJobMetrics } from '@/hooks/useOptimizedJobMetrics';
import { useCategorizedJobs } from '@/hooks/useCategorizedJobs'; import { useCategorizedJobs } from '@/hooks/useCategorizedJobs';
const Index = () => { const Index = () => {
@@ -71,7 +71,7 @@ const Index = () => {
// Always call hooks before any conditional returns // Always call hooks before any conditional returns
const { regularJobs, trackedJobs } = useCategorizedJobs(jobs, searchQuery); const { regularJobs, trackedJobs } = useCategorizedJobs(jobs, searchQuery);
const { totalJobs, totalProfit, totalRevenue, calculateJobRevenue, calculateJobProfit } = useJobMetrics(regularJobs); const { totalJobs, totalProfit, totalRevenue, calculateJobRevenue, calculateJobProfit } = useOptimizedJobMetrics(regularJobs);
if (loading) { if (loading) {
return ( return (
@@ -138,7 +138,7 @@ const Index = () => {
onBatchExpenditure={() => setShowBatchExpenditureForm(true)} onBatchExpenditure={() => setShowBatchExpenditureForm(true)}
/> />
<JobsSection <OptimizedJobsSection
regularJobs={regularJobs} regularJobs={regularJobs}
trackedJobs={trackedJobs} trackedJobs={trackedJobs}
collapsedGroups={collapsedGroups} collapsedGroups={collapsedGroups}

61
src/types/industry.ts Normal file
View File

@@ -0,0 +1,61 @@
export type IsoDateString = string;
export type RecordIdString = string;
export enum IndJobStatusOptions {
"Planned" = "Planned",
"Acquisition" = "Acquisition",
"Running" = "Running",
"Done" = "Done",
"Selling" = "Selling",
"Closed" = "Closed",
"Tracked" = "Tracked",
"Staging" = "Staging",
"Inbound" = "Inbound",
"Outbound" = "Outbound",
"Delivered" = "Delivered",
"Queued" = "Queued",
}
export type IndBillitemRecord = {
created?: IsoDateString;
id: string;
name: string;
quantity: number;
updated?: IsoDateString;
};
export type IndTransactionRecord = {
buyer?: string;
corporation?: string;
created?: IsoDateString;
date: IsoDateString;
id: string;
itemName: string;
job?: RecordIdString;
location?: string;
quantity: number;
totalPrice: number;
unitPrice: number;
updated?: IsoDateString;
wallet?: string;
};
export type IndJob = {
billOfMaterials?: IndBillitemRecord[];
consumedMaterials?: IndBillitemRecord[];
created?: IsoDateString;
expenditures?: IndTransactionRecord[];
id: string;
income?: IndTransactionRecord[];
jobEnd?: IsoDateString;
jobStart?: IsoDateString;
outputItem: string;
outputQuantity: number;
produced?: number;
saleEnd?: IsoDateString;
saleStart?: IsoDateString;
status: IndJobStatusOptions;
updated?: IsoDateString;
projectedCost?: number;
projectedRevenue?: number;
};