wulf-pulse/hooks/use-time-entries.ts

463 lines
13 KiB
TypeScript
Raw Permalink Normal View History

'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { TimeEntry } from '@/lib/types/database';
import {
TimelineEvent,
AnalyticsInsight,
LLMAnalysisResponse,
AggregateAnalysis
} from '@/lib/types/analytics';
import {
analyticsIntegration,
EnrichedTimeEntry,
EnrichmentOptions
} from '@/lib/services/analytics-integration';
import { analyticsEngine } from '@/lib/services/analytics-engine';
import { llmAnalyzer } from '@/lib/services/llm-analyzer';
export interface TimeEntriesFilter {
startDate?: string;
endDate?: string;
resourceIds?: number[];
ticketIds?: number[];
taskIds?: number[];
projectIds?: number[];
companyIds?: number[];
minHours?: number;
maxHours?: number;
billable?: boolean;
approved?: boolean;
search?: string;
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface UseTimeEntriesOptions {
autoFetch?: boolean;
enableAnalysis?: boolean;
enableEnrichment?: boolean;
enrichmentOptions?: EnrichmentOptions;
cacheKey?: string;
cacheTimeout?: number; // milliseconds
}
export interface TimeEntriesState {
timeEntries: TimeEntry[];
enrichedTimeEntries: EnrichedTimeEntry[];
timelineEvents: TimelineEvent[];
analysis: AggregateAnalysis | null;
insights: AnalyticsInsight[];
llmAnalysis: LLMAnalysisResponse | null;
loading: boolean;
error: string | null;
pagination: {
total: number;
limit: number;
offset: number;
hasMore: boolean;
};
}
export function useTimeEntries(
initialFilter: TimeEntriesFilter = {},
options: UseTimeEntriesOptions = {}
) {
const {
autoFetch = true,
enableAnalysis = true,
enableEnrichment = true,
enrichmentOptions = {
includeResourceInfo: true,
includeTicketInfo: true,
includeProjectInfo: true,
includeCompanyInfo: true,
includeAnalysis: true,
},
cacheKey = 'time-entries',
cacheTimeout = 5 * 60 * 1000, // 5 minutes
} = options;
const [filter, setFilter] = useState<TimeEntriesFilter>(initialFilter);
const [state, setState] = useState<TimeEntriesState>({
timeEntries: [],
enrichedTimeEntries: [],
timelineEvents: [],
analysis: null,
insights: [],
llmAnalysis: null,
loading: false,
error: null,
pagination: {
total: 0,
limit: 100,
offset: 0,
hasMore: false,
},
});
// Simple cache implementation
const cache = useMemo(() => new Map<string, { data: any; timestamp: number }>(), []);
const getCacheKey = useCallback((filter: TimeEntriesFilter) => {
return `${cacheKey}-${JSON.stringify(filter)}`;
}, [cacheKey]);
const getCachedData = useCallback((key: string) => {
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < cacheTimeout) {
return cached.data;
}
cache.delete(key);
return null;
}, [cache, cacheTimeout]);
const setCachedData = useCallback((key: string, data: any) => {
cache.set(key, { data, timestamp: Date.now() });
}, [cache]);
// Fetch time entries from API
const fetchTimeEntries = useCallback(async (filterOverrides: Partial<TimeEntriesFilter> = {}) => {
const currentFilter = { ...filter, ...filterOverrides };
const cacheKey = getCacheKey(currentFilter);
// Check cache first
const cached = getCachedData(cacheKey);
if (cached) {
setState(cached);
return cached;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
// Build query string
const params = new URLSearchParams();
if (currentFilter.startDate) params.append('start_date', currentFilter.startDate);
if (currentFilter.endDate) params.append('end_date', currentFilter.endDate);
if (currentFilter.minHours) params.append('min_hours', currentFilter.minHours.toString());
if (currentFilter.maxHours) params.append('max_hours', currentFilter.maxHours.toString());
if (currentFilter.billable !== undefined) params.append('billable', currentFilter.billable.toString());
if (currentFilter.approved !== undefined) params.append('approved', currentFilter.approved.toString());
if (currentFilter.search) params.append('search', currentFilter.search);
if (currentFilter.limit) params.append('limit', currentFilter.limit.toString());
if (currentFilter.offset) params.append('offset', currentFilter.offset.toString());
if (currentFilter.sortBy) params.append('sort_by', currentFilter.sortBy);
if (currentFilter.sortOrder) params.append('sort_order', currentFilter.sortOrder);
if (currentFilter.resourceIds?.length) {
params.append('resource_ids', currentFilter.resourceIds.join(','));
}
if (currentFilter.ticketIds?.length) {
params.append('ticket_ids', currentFilter.ticketIds.join(','));
}
if (currentFilter.taskIds?.length) {
params.append('task_ids', currentFilter.taskIds.join(','));
}
if (currentFilter.projectIds?.length) {
params.append('project_ids', currentFilter.projectIds.join(','));
}
if (currentFilter.companyIds?.length) {
params.append('company_ids', currentFilter.companyIds.join(','));
}
const response = await fetch(`/api/data/time-entries?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch time entries: ${response.statusText}`);
}
const data = await response.json();
const timeEntries: TimeEntry[] = data.timeEntries || [];
let enrichedTimeEntries: EnrichedTimeEntry[] = [];
let timelineEvents: TimelineEvent[] = [];
let analysis: AggregateAnalysis | null = null;
let insights: AnalyticsInsight[] = [];
// Enrich data if enabled
if (enableEnrichment && timeEntries.length > 0) {
enrichedTimeEntries = await analyticsIntegration.enrichTimeEntries(
timeEntries,
enrichmentOptions
);
timelineEvents = await analyticsIntegration.generateTimelineEvents(
timeEntries,
enrichmentOptions
);
}
// Generate analysis if enabled
if (enableAnalysis && timeEntries.length > 0) {
const analysisResult = await analyticsIntegration.generateComprehensiveAnalysis(
timeEntries,
enrichmentOptions
);
analysis = analysisResult.analysis;
insights = analysisResult.entityInsights;
}
const newState: TimeEntriesState = {
timeEntries,
enrichedTimeEntries,
timelineEvents,
analysis,
insights,
llmAnalysis: null,
loading: false,
error: null,
pagination: data.pagination || {
total: timeEntries.length,
limit: currentFilter.limit || 100,
offset: currentFilter.offset || 0,
hasMore: false,
},
};
setState(newState);
setCachedData(cacheKey, newState);
return newState;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState(prev => ({
...prev,
loading: false,
error: errorMessage,
}));
throw error;
}
}, [filter, enableEnrichment, enableAnalysis, enrichmentOptions, getCacheKey, getCachedData, setCachedData]);
// Generate LLM analysis
const generateLLMAnalysis = useCallback(async (
analysisType: 'productivity' | 'quality' | 'patterns' | 'anomalies' | 'comprehensive' = 'comprehensive'
) => {
if (state.timeEntries.length === 0) {
return null;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const llmAnalysis = await analyticsIntegration.generateLLMAnalysis(
state.timeEntries,
analysisType,
enrichmentOptions
);
setState(prev => ({
...prev,
llmAnalysis,
loading: false,
}));
return llmAnalysis;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState(prev => ({
...prev,
loading: false,
error: errorMessage,
}));
throw error;
}
}, [state.timeEntries, enrichmentOptions]);
// Refresh data
const refresh = useCallback(() => {
return fetchTimeEntries();
}, [fetchTimeEntries]);
// Update filter
const updateFilter = useCallback((newFilter: Partial<TimeEntriesFilter>) => {
setFilter(prev => ({ ...prev, ...newFilter }));
}, []);
// Reset filter
const resetFilter = useCallback(() => {
setFilter(initialFilter);
}, [initialFilter]);
// Load more data (pagination)
const loadMore = useCallback(() => {
if (!state.pagination.hasMore) return;
return fetchTimeEntries({
offset: state.pagination.offset + state.pagination.limit,
});
}, [fetchTimeEntries, state.pagination]);
// Export data
const exportData = useCallback(async (format: 'csv' | 'excel' | 'pdf' | 'json' = 'csv') => {
try {
const params = new URLSearchParams({ format });
// Apply current filter to export
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
params.append(key, value.join(','));
} else {
params.append(key, String(value));
}
}
});
const response = await fetch(`/api/data/time-entries/export?${params}`);
if (!response.ok) {
throw new Error(`Failed to export data: ${response.statusText}`);
}
// Get filename from response headers or create default
const contentDisposition = response.headers.get('content-disposition');
const filename = contentDisposition
? contentDisposition.split('filename=')[1]?.replace(/"/g, '')
: `time-entries-${new Date().toISOString().split('T')[0]}.${format}`;
// Download file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setState(prev => ({ ...prev, error: errorMessage }));
throw error;
}
}, [filter]);
// Auto-fetch on filter change
useEffect(() => {
if (autoFetch) {
fetchTimeEntries();
}
}, [filter, autoFetch, fetchTimeEntries]);
// Memoized computed values
const computed = useMemo(() => ({
totalEntries: state.timeEntries.length,
totalHours: state.timeEntries.reduce((sum, entry) => sum + entry.hours_worked, 0),
averageHoursPerEntry: state.timeEntries.length > 0
? state.timeEntries.reduce((sum, entry) => sum + entry.hours_worked, 0) / state.timeEntries.length
: 0,
billableEntries: state.timeEntries.filter(entry => entry.billable).length,
approvedEntries: state.timeEntries.filter(entry => entry.approved).length,
averageScore: state.analysis?.scores.overall || 0,
}), [state.timeEntries, state.analysis]);
return {
// State
...state,
// Computed values
computed,
// Actions
fetchTimeEntries,
generateLLMAnalysis,
refresh,
updateFilter,
resetFilter,
loadMore,
exportData,
// Filter
filter,
setFilter,
};
}
// Hook for individual time entry analysis
export function useTimeEntryAnalysis(timeEntryId: number) {
const [timeEntry, setTimeEntry] = useState<TimeEntry | null>(null);
const [analysis, setAnalysis] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTimeEntry = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/data/time-entries/${timeEntryId}`);
if (!response.ok) {
throw new Error(`Failed to fetch time entry: ${response.statusText}`);
}
const data = await response.json();
setTimeEntry(data.timeEntry);
// Generate analysis
if (data.timeEntry) {
const entryAnalysis = analyticsEngine.analyzeTimeEntry(data.timeEntry);
setAnalysis(entryAnalysis);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
} finally {
setLoading(false);
}
}, [timeEntryId]);
useEffect(() => {
if (timeEntryId) {
fetchTimeEntry();
}
}, [timeEntryId, fetchTimeEntry]);
return {
timeEntry,
analysis,
loading,
error,
refresh: fetchTimeEntry,
};
}
// Hook for real-time analytics updates
export function useRealTimeAnalytics(refreshInterval: number = 60000) { // 1 minute default
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [isConnected, setIsConnected] = useState(false);
const timeEntriesHook = useTimeEntries();
// Set up real-time updates
useEffect(() => {
const interval = setInterval(() => {
timeEntriesHook.refresh();
setLastUpdate(new Date());
}, refreshInterval);
setIsConnected(true);
return () => {
clearInterval(interval);
setIsConnected(false);
};
}, [refreshInterval, timeEntriesHook]);
return {
...timeEntriesHook,
lastUpdate,
isConnected,
};
}