From 5cb8f9844bb9d44abd99aa399b12cb89d9b28989 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 19:46:29 +0000 Subject: [PATCH 01/14] refactor: split dataService.ts into separate service modules Extract LocalStorageService and SupabaseService classes into their own files, reducing dataService.ts from 1,267 lines to a thin interface + factory module (~45 lines). All public exports remain backward compatible. https://claude.ai/code/session_012AmNjF3Ju9VJwSWybJqBEw --- src/services/dataService.ts | 1287 +-------------------------- src/services/localStorageService.ts | 122 +++ src/services/supabaseService.ts | 1003 +++++++++++++++++++++ 3 files changed, 1158 insertions(+), 1254 deletions(-) create mode 100644 src/services/localStorageService.ts create mode 100644 src/services/supabaseService.ts diff --git a/src/services/dataService.ts b/src/services/dataService.ts index 1509862..f6b1c6e 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -1,1267 +1,46 @@ -import { supabase, trackDbCall, getCachedUser, getCachedProjects, setCachedProjects, getCachedCategories, setCachedCategories, clearDataCaches, trackAuthCall } from '@/lib/supabase'; -import { Task, DayRecord, Project } from '@/contexts/TimeTrackingContext'; -import { TaskCategory } from '@/config/categories'; +import { DayRecord, Project } from "@/contexts/TimeTrackingContext"; +import { Task } from "@/contexts/TimeTrackingContext"; +import { TaskCategory } from "@/config/categories"; +import { LocalStorageService } from "@/services/localStorageService"; +import { SupabaseService } from "@/services/supabaseService"; -// Storage keys for localStorage -export const STORAGE_KEYS = { - CURRENT_DAY: 'timetracker_current_day', - ARCHIVED_DAYS: 'timetracker_archived_days', - PROJECTS: 'timetracker_projects', - CATEGORIES: 'timetracker_categories' -}; +export { STORAGE_KEYS } from "@/services/localStorageService"; // Current day data structure export interface CurrentDayData { - isDayStarted: boolean; - dayStartTime: Date | null; - currentTask: Task | null; - tasks: Task[]; + isDayStarted: boolean; + dayStartTime: Date | null; + currentTask: Task | null; + tasks: Task[]; } // Data service interface export interface DataService { - // Current day operations - saveCurrentDay: (data: CurrentDayData) => Promise; - getCurrentDay: () => Promise; - - // Archived days operations - saveArchivedDays: (days: DayRecord[]) => Promise; - getArchivedDays: () => Promise; - updateArchivedDay: ( - id: string, - updates: Partial - ) => Promise; - deleteArchivedDay: (id: string) => Promise; - - // Projects operations - saveProjects: (projects: Project[]) => Promise; - getProjects: () => Promise; - - // Categories operations - saveCategories: (categories: TaskCategory[]) => Promise; - getCategories: () => Promise; - - // Migration operations - migrateFromLocalStorage: () => Promise; - migrateToLocalStorage: () => Promise; -} - -// localStorage implementation -class LocalStorageService implements DataService { - async saveCurrentDay(data: CurrentDayData): Promise { - localStorage.setItem(STORAGE_KEYS.CURRENT_DAY, JSON.stringify(data)); - } - - async getCurrentDay(): Promise { - try { - const saved = localStorage.getItem(STORAGE_KEYS.CURRENT_DAY); - if (!saved) return null; - - const data = JSON.parse(saved); - return { - ...data, - dayStartTime: data.dayStartTime ? new Date(data.dayStartTime) : null, - tasks: data.tasks.map((task: Task) => ({ - ...task, - startTime: new Date(task.startTime), - endTime: task.endTime ? new Date(task.endTime) : undefined - })), - currentTask: data.currentTask - ? { - ...data.currentTask, - startTime: new Date(data.currentTask.startTime), - endTime: data.currentTask.endTime - ? new Date(data.currentTask.endTime) - : undefined - } - : null - }; - } catch (error) { - console.error('Error loading current day from localStorage:', error); - return null; - } - } - - async saveArchivedDays(days: DayRecord[]): Promise { - localStorage.setItem(STORAGE_KEYS.ARCHIVED_DAYS, JSON.stringify(days)); - } - - async getArchivedDays(): Promise { - try { - const saved = localStorage.getItem(STORAGE_KEYS.ARCHIVED_DAYS); - if (!saved) return []; - - const data = JSON.parse(saved); - return data.map((day: DayRecord) => ({ - ...day, - startTime: new Date(day.startTime), - endTime: new Date(day.endTime), - tasks: day.tasks.map((task: Task) => ({ - ...task, - startTime: new Date(task.startTime), - endTime: task.endTime ? new Date(task.endTime) : undefined - })) - })); - } catch (error) { - console.error('Error loading archived days from localStorage:', error); - return []; - } - } - - async updateArchivedDay( - dayId: string, - updates: Partial - ): Promise { - const days = await this.getArchivedDays(); - const updatedDays = days.map((day) => - day.id === dayId ? { ...day, ...updates } : day - ); - await this.saveArchivedDays(updatedDays); - } - - async deleteArchivedDay(dayId: string): Promise { - const days = await this.getArchivedDays(); - const filteredDays = days.filter((day) => day.id !== dayId); - await this.saveArchivedDays(filteredDays); - } - - async saveProjects(projects: Project[]): Promise { - localStorage.setItem(STORAGE_KEYS.PROJECTS, JSON.stringify(projects)); - } - - async getProjects(): Promise { - try { - const saved = localStorage.getItem(STORAGE_KEYS.PROJECTS); - return saved ? JSON.parse(saved) : []; - } catch (error) { - console.error('Error loading projects from localStorage:', error); - return []; - } - } - - async saveCategories(categories: TaskCategory[]): Promise { - localStorage.setItem(STORAGE_KEYS.CATEGORIES, JSON.stringify(categories)); - } - - async getCategories(): Promise { - try { - const saved = localStorage.getItem(STORAGE_KEYS.CATEGORIES); - return saved ? JSON.parse(saved) : []; - } catch (error) { - console.error('Error loading categories from localStorage:', error); - return []; - } - } - - async migrateFromLocalStorage(): Promise { - // No-op for localStorage service - } - - async migrateToLocalStorage(): Promise { - // No-op for localStorage service - already in localStorage - } -} - -// Supabase implementation with graceful fallback -class SupabaseService implements DataService { - // Schema detection with permanent caching - private hasNewSchema: boolean | null = null; - private static schemaChecked: boolean = false; - private static globalSchemaResult: boolean | null = null; - - private async checkNewSchema(): Promise { - // Use global cache first (survives across service instances) - if (SupabaseService.schemaChecked && SupabaseService.globalSchemaResult !== null) { - this.hasNewSchema = SupabaseService.globalSchemaResult; - return this.hasNewSchema; - } - - // Use instance cache - if (this.hasNewSchema !== null) { - return this.hasNewSchema; - } - - try { - // Check if we have a valid authenticated user first - const { - data: { user }, - error: userError - } = await supabase.auth.getUser(); - trackAuthCall('getUser', 'SupabaseService.checkNewSchema'); - - if (userError || !user) { - console.warn('User not authenticated, cannot check schema'); - this.hasNewSchema = false; - SupabaseService.globalSchemaResult = false; - SupabaseService.schemaChecked = true; - return false; - } - - // Try to query the current_day table to see if the new schema exists - // We use current_day because it's simpler and always exists in the new schema - const { error } = await supabase - .from('current_day') - .select('user_id') - .eq('user_id', user.id) - .limit(1); - trackDbCall('select', 'current_day', 'SupabaseService.checkNewSchema'); - - if (error) { - console.warn('New schema not detected:', error.message); - this.hasNewSchema = false; - SupabaseService.globalSchemaResult = false; - SupabaseService.schemaChecked = true; - return false; - } - - this.hasNewSchema = true; - SupabaseService.globalSchemaResult = true; - SupabaseService.schemaChecked = true; - console.log('✅ New schema confirmed and cached globally'); - return true; - } catch (error) { - console.warn( - 'Error checking schema, assuming new schema exists:', - error - ); - // Default to true since your schema.sql shows the new schema - this.hasNewSchema = true; - SupabaseService.globalSchemaResult = true; - SupabaseService.schemaChecked = true; - return true; - } - } - async saveCurrentDay(data: CurrentDayData): Promise { - console.log('💾 SupabaseService: Saving current day...', { - tasksCount: data.tasks.length, - isDayStarted: data.isDayStarted, - hasCurrentTask: !!data.currentTask - }); - - // Get cached user (much more efficient than repeated auth calls) - const user = await getCachedUser(); - console.log('👤 User authenticated:', user.id); - - // Get categories and projects for proper name resolution - const categories = await getCachedCategories() || []; - const projects = await getCachedProjects() || []; - - try { - // Start a transaction-like approach with batch operations - - // 1. Save current day state - const { error: currentDayError } = await supabase - .from('current_day') - .upsert({ - user_id: user.id, - is_day_started: data.isDayStarted, - day_start_time: data.dayStartTime?.toISOString(), - current_task_id: data.currentTask?.id || null - }); - trackDbCall('upsert', 'current_day'); - - if (currentDayError) { - console.error('❌ Error saving current day state:', currentDayError); - throw currentDayError; - } - - // 2. Handle tasks efficiently - only if there are changes - if (data.tasks.length === 0) { - // If no tasks, just delete all current tasks - const { error: deleteError } = await supabase - .from('tasks') - .delete() - .eq('user_id', user.id) - .eq('is_current', true); - trackDbCall('delete', 'tasks'); - - if (deleteError) { - console.error('❌ Error deleting all current tasks:', deleteError); - throw deleteError; - } - console.log('🗑️ Deleted all current tasks (no tasks provided)'); - } else { - // Get existing task IDs to minimize database operations - const { data: existingTasks } = await supabase - .from('tasks') - .select('id') - .eq('user_id', user.id) - .eq('is_current', true); - trackDbCall('select', 'tasks'); - - const existingTaskIds = new Set(existingTasks?.map(t => t.id) || []); - const newTaskIds = new Set(data.tasks.map(t => t.id)); - - // 3. Delete obsolete tasks (exist in DB but not in current data) - const tasksToDelete = Array.from(existingTaskIds).filter(id => !newTaskIds.has(id)); - if (tasksToDelete.length > 0) { - const { error: deleteError } = await supabase - .from('tasks') - .delete() - .eq('user_id', user.id) - .eq('is_current', true) - .in('id', tasksToDelete); - trackDbCall('delete', 'tasks'); - - if (deleteError) { - console.error('❌ Error deleting obsolete tasks:', deleteError); - throw deleteError; - } - console.log('🗑️ Deleted obsolete tasks:', tasksToDelete.length); - } - - // 4. Upsert current tasks (single batch operation) - const tasksToUpsert = data.tasks.map((task) => { - // Look up actual category and project objects to get proper IDs and names - const category = categories.find(c => c.id === task.category); - const project = projects.find(p => p.name === task.project); - - return { - id: task.id, - user_id: user.id, - title: task.title, - description: task.description || null, - start_time: task.startTime.toISOString(), - end_time: task.endTime?.toISOString() || null, - duration: task.duration || null, - project_id: project?.id || task.project || null, - project_name: task.project || null, - client: task.client || null, - category_id: task.category || null, - category_name: category?.name || null, - day_record_id: null, - is_current: true - }; - }); - - console.log('📝 Upserting tasks:', tasksToUpsert.length); - - const { error: tasksError } = await supabase - .from('tasks') - .upsert(tasksToUpsert, { - onConflict: 'id' - }); - trackDbCall('upsert', 'tasks'); - - if (tasksError) { - console.error('❌ Error upserting tasks:', tasksError); - throw tasksError; - } - - console.log('✅ Tasks upserted successfully'); - } - - console.log('✅ Current day saved successfully'); - } catch (error) { - console.error('❌ Error in saveCurrentDay:', error); - throw error; - } - } async getCurrentDay(): Promise { - console.log('🔄 SupabaseService: Loading current day...'); - - // Get cached user - const user = await getCachedUser(); - console.log('👤 User authenticated:', user.id); - - // Get current day state - const { data: currentDayData, error: currentDayError } = await supabase - .from('current_day') - .select('*') - .eq('user_id', user.id) - .single(); - - if (currentDayError && currentDayError.code !== 'PGRST116') { - console.error('❌ Error loading current day state:', currentDayError); - throw currentDayError; - } - - // Get current tasks - const { data: tasksData, error: tasksError } = await supabase - .from('tasks') - .select('*') - .eq('user_id', user.id) - .eq('is_current', true) - .order('start_time', { ascending: true }); - - if (tasksError) { - console.error('❌ Error loading current tasks:', tasksError); - throw tasksError; - } - - console.log('📊 Loaded data:', { - hasDayData: !!currentDayData, - tasksCount: tasksData?.length || 0 - }); - - if (!currentDayData && (!tasksData || tasksData.length === 0)) { - console.log('📭 No current day data found'); - return null; - } - - // Convert tasks from database format - const tasks: Task[] = (tasksData || []).map((task) => ({ - id: task.id, - title: task.title, - description: task.description || undefined, - startTime: new Date(task.start_time), - endTime: task.end_time ? new Date(task.end_time) : undefined, - duration: task.duration || undefined, - project: task.project_name || undefined, - client: task.client || undefined, - category: task.category_id || undefined, // Use category_id, not category_name - insertedAt: task.inserted_at ? new Date(task.inserted_at) : undefined, - updatedAt: task.updated_at ? new Date(task.updated_at) : undefined - })); - - // Find current task - const currentTask = currentDayData?.current_task_id - ? tasks.find((task) => task.id === currentDayData.current_task_id) || null - : null; - - const result = { - isDayStarted: currentDayData?.is_day_started || false, - dayStartTime: currentDayData?.day_start_time - ? new Date(currentDayData.day_start_time) - : null, - tasks, - currentTask - }; - - console.log('✅ Current day loaded:', { - isDayStarted: result.isDayStarted, - tasksCount: result.tasks.length, - hasCurrentTask: !!result.currentTask - }); - - return result; - } - - async saveArchivedDays(days: DayRecord[]): Promise { - console.log('📁 SupabaseService: Saving archived days...', days.length); - - // Get cached user - const user = await getCachedUser(); - console.log('👤 User authenticated:', user.id); - - // Get categories and projects for proper name resolution - const categories = await getCachedCategories() || []; - const projects = await getCachedProjects() || []; - - // Validate user ID upfront - if (!user.id) { - throw new Error('User ID is null - cannot save archived data'); - } - - if (days.length === 0) { - console.log('📭 No archived days to save - clearing existing data'); - // Still need to clear existing data if no days provided - await supabase.from('tasks').delete().eq('user_id', user.id).eq('is_current', false); - await supabase.from('archived_days').delete().eq('user_id', user.id); - return; - } - - // Prepare data for upsert (not insert) - const archivedDaysToUpsert = days.map((day) => ({ - id: day.id, - user_id: user.id, - date: day.date, - total_duration: day.totalDuration, - start_time: day.startTime.toISOString(), - end_time: day.endTime.toISOString(), - notes: day.notes - // Note: inserted_at and updated_at are NOT included - // inserted_at will be set by DB on first insert, preserved on update - // updated_at will be updated by DB trigger only when data actually changes - })); - - const allTasks = days.flatMap((day) => - day.tasks.map((task) => { - // Look up actual category and project objects to get proper IDs and names - const category = categories.find(c => c.id === task.category); - const project = projects.find(p => p.name === task.project); - - return { - id: task.id, // Use original task ID for stable identity - user_id: user.id, - title: task.title, - description: task.description || null, - start_time: task.startTime.toISOString(), - end_time: task.endTime?.toISOString() || null, - duration: task.duration || null, - project_id: project?.id || task.project || null, - project_name: task.project || null, - client: task.client || null, - category_id: task.category || null, - category_name: category?.name || null, - day_record_id: day.id, - is_current: false - // Note: inserted_at and updated_at are NOT included - }; - }) - ); - - console.log('🔄 Prepared data - Days:', archivedDaysToUpsert.length, 'Tasks:', allTasks.length); - - try { - // Step 1: Get existing data to determine what to delete - const { data: existingDays } = await supabase - .from('archived_days') - .select('id') - .eq('user_id', user.id); - trackDbCall('select', 'archived_days'); - - const { data: existingTasks } = await supabase - .from('tasks') - .select('id') - .eq('user_id', user.id) - .eq('is_current', false); - trackDbCall('select', 'tasks'); - - const existingDayIds = new Set(existingDays?.map(d => d.id) || []); - const newDayIds = new Set(days.map(d => d.id)); - const existingTaskIds = new Set(existingTasks?.map(t => t.id) || []); - const newTaskIds = new Set(allTasks.map(t => t.id)); - - // Step 2: Delete archived days that no longer exist in local state - const daysToDelete = Array.from(existingDayIds).filter(id => !newDayIds.has(id)); - if (daysToDelete.length > 0) { - console.log('🗑️ Deleting obsolete archived days:', daysToDelete.length); - const { error: deleteDaysError } = await supabase - .from('archived_days') - .delete() - .eq('user_id', user.id) - .in('id', daysToDelete); - trackDbCall('delete', 'archived_days'); - - if (deleteDaysError) { - console.error('❌ Error deleting obsolete days:', deleteDaysError); - throw deleteDaysError; - } - } - - // Step 3: Delete archived tasks that no longer exist in local state - const tasksToDelete = Array.from(existingTaskIds).filter(id => !newTaskIds.has(id)); - if (tasksToDelete.length > 0) { - console.log('🗑️ Deleting obsolete archived tasks:', tasksToDelete.length); - const { error: deleteTasksError } = await supabase - .from('tasks') - .delete() - .eq('user_id', user.id) - .eq('is_current', false) - .in('id', tasksToDelete); - trackDbCall('delete', 'tasks'); - - if (deleteTasksError) { - console.error('❌ Error deleting obsolete tasks:', deleteTasksError); - throw deleteTasksError; - } - } - - // Step 4: Upsert archived days (insert new, update existing) - console.log('📅 Upserting archived days...'); - const { error: daysError } = await supabase - .from('archived_days') - .upsert(archivedDaysToUpsert, { - onConflict: 'id' - }); - trackDbCall('upsert', 'archived_days'); - - if (daysError) { - console.error('❌ Error upserting archived days:', daysError); - throw daysError; - } - - console.log('✅ Archived days upserted successfully'); - - // Step 5: Upsert archived tasks (insert new, update existing) - if (allTasks.length > 0) { - console.log('📝 Upserting archived tasks...', allTasks.length); - - const { error: tasksError } = await supabase - .from('tasks') - .upsert(allTasks, { - onConflict: 'id' - }); - trackDbCall('upsert', 'tasks'); - - if (tasksError) { - console.error('❌ Error upserting archived tasks:', tasksError); - console.error('❌ Failed task data sample:', allTasks[0]); - throw tasksError; - } - - console.log('✅ Archived tasks upserted successfully'); - } - - console.log('🎉 All archived data saved successfully'); - - // Verification step - check that the data was actually saved - console.log('🔍 Verifying archived data was saved correctly...'); - await this.verifyArchivedDataIntegrity(days); - - } catch (error) { - console.error('💥 Archiving save failed:', error); - console.error('🚨 Data that failed to save:', { - daysCount: archivedDaysToUpsert.length, - tasksCount: allTasks.length, - firstDay: archivedDaysToUpsert[0]?.date, - error: error - }); - - // Re-throw to let the caller handle the error - throw error; - } - } - - /** - * Verifies that archived data was saved correctly by checking the database - */ - private async verifyArchivedDataIntegrity(expectedDays: DayRecord[]): Promise { - try { - const user = await getCachedUser(); - - // Check archived days count - const { data: savedDays, error: daysError } = await supabase - .from('archived_days') - .select('id') - .eq('user_id', user.id); - - if (daysError) { - console.warn('⚠️ Could not verify archived days:', daysError); - return; - } - - // Check tasks count - const { data: savedTasks, error: tasksError } = await supabase - .from('tasks') - .select('id') - .eq('user_id', user.id) - .eq('is_current', false); - - if (tasksError) { - console.warn('⚠️ Could not verify archived tasks:', tasksError); - return; - } - - const expectedTasksCount = expectedDays.reduce((sum, day) => sum + day.tasks.length, 0); - - console.log('📊 Archive verification results:', { - expectedDays: expectedDays.length, - savedDays: savedDays?.length || 0, - expectedTasks: expectedTasksCount, - savedTasks: savedTasks?.length || 0 - }); - - if (savedDays?.length !== expectedDays.length) { - console.error('❌ Archive verification failed: Day count mismatch'); - } - - if (savedTasks?.length !== expectedTasksCount) { - console.error('❌ Archive verification failed: Task count mismatch'); - throw new Error(`Archive verification failed: Expected ${expectedTasksCount} tasks, found ${savedTasks?.length}`); - } - - console.log('✅ Archive verification passed'); - } catch (error) { - console.error('❌ Archive verification failed:', error); - throw error; - } - } - - async getArchivedDays(): Promise { - console.log('📁 SupabaseService: Loading archived days...'); - - // Get cached user - const user = await getCachedUser(); - - console.log('👤 User authenticated:', user.id); - - // Get archived days - const { data: daysData, error: daysError } = await supabase - .from('archived_days') - .select('*') - .eq('user_id', user.id) - .order('start_time', { ascending: false }); - - if (daysError) { - console.error('❌ Error loading archived days:', daysError); - throw daysError; - } - - if (!daysData || daysData.length === 0) { - console.log('📭 No archived days found'); - return []; - } - - console.log('📊 Found archived days:', daysData.length); - - // Get all archived tasks - const { data: tasksData, error: tasksError } = await supabase - .from('tasks') - .select('*') - .eq('user_id', user.id) - .eq('is_current', false) - .order('start_time', { ascending: true }); - - if (tasksError) { - console.error('❌ Error loading archived tasks:', tasksError); - throw tasksError; - } - - console.log('📝 Found archived tasks:', tasksData?.length || 0); - console.log('📝 Task data sample:', tasksData?.slice(0, 3).map(task => ({ - id: task.id, - title: task.title, - day_record_id: task.day_record_id, - is_current: task.is_current - }))); - - // Group tasks by day record - const tasksByDay: Record = {}; - (tasksData || []).forEach((task) => { - if (!task.day_record_id) return; - - if (!tasksByDay[task.day_record_id]) { - tasksByDay[task.day_record_id] = []; - } - - tasksByDay[task.day_record_id].push({ - id: task.id, - title: task.title, - description: task.description || undefined, - startTime: new Date(task.start_time), - endTime: task.end_time ? new Date(task.end_time) : undefined, - duration: task.duration || undefined, - project: task.project_name || undefined, - client: task.client || undefined, - category: task.category_id || undefined, // Use category_id, not category_name - insertedAt: task.inserted_at ? new Date(task.inserted_at) : undefined, - updatedAt: task.updated_at ? new Date(task.updated_at) : undefined - }); - }); - - console.log('📝 Tasks grouped by day:', Object.keys(tasksByDay).map(dayId => ({ - dayId, - taskCount: tasksByDay[dayId].length - }))); - - // Combine days with their tasks - const result = daysData.map((day) => ({ - id: day.id, - date: day.date, - tasks: tasksByDay[day.id] || [], - totalDuration: day.total_duration, - startTime: new Date(day.start_time), - endTime: new Date(day.end_time), - notes: day.notes, - insertedAt: day.inserted_at ? new Date(day.inserted_at) : undefined, - updatedAt: day.updated_at ? new Date(day.updated_at) : undefined - })); - - console.log('✅ Archived days loaded:', { - daysCount: result.length, - totalTasks: result.reduce((sum, day) => sum + day.tasks.length, 0) - }); - - return result; - } - - async updateArchivedDay( - dayId: string, - updates: Partial - ): Promise { - const user = await getCachedUser(); - - // Get categories and projects for proper name resolution - const categories = await getCachedCategories(); - const projects = await getCachedProjects(); - - const updateData: Record = {}; - - if (updates.date) updateData.date = updates.date; - if (updates.totalDuration !== undefined) - updateData.total_duration = updates.totalDuration; - if (updates.startTime) - updateData.start_time = updates.startTime.toISOString(); - if (updates.endTime) updateData.end_time = updates.endTime.toISOString(); - if (updates.notes !== undefined) updateData.notes = updates.notes; - - // Update archived day - const { error: dayError } = await supabase - .from('archived_days') - .update(updateData) - .eq('id', dayId) - .eq('user_id', user.id); - trackDbCall('update', 'archived_days'); - - if (dayError) throw dayError; - - // Update tasks if provided - use upsert strategy like saveArchivedDays - if (updates.tasks) { - // Get existing tasks for this day - const { data: existingTasks } = await supabase - .from('tasks') - .select('id') - .eq('day_record_id', dayId) - .eq('user_id', user.id) - .eq('is_current', false); - trackDbCall('select', 'tasks'); - - const existingTaskIds = new Set(existingTasks?.map(t => t.id) || []); - const newTaskIds = new Set(updates.tasks.map(t => t.id)); - - // Delete tasks that no longer exist in the update - const tasksToDelete = Array.from(existingTaskIds).filter(id => !newTaskIds.has(id)); - if (tasksToDelete.length > 0) { - const { error: deleteError } = await supabase - .from('tasks') - .delete() - .eq('day_record_id', dayId) - .eq('user_id', user.id) - .eq('is_current', false) - .in('id', tasksToDelete); - trackDbCall('delete', 'tasks'); - - if (deleteError) throw deleteError; - } - - // Upsert updated tasks (insert new, update existing) - if (updates.tasks.length > 0) { - const tasksToUpsert = updates.tasks.map((task) => { - // Look up actual category and project objects to get proper IDs and names - const category = categories.find(c => c.id === task.category); - const project = projects.find(p => p.name === task.project); - - return { - id: task.id, // Use original task ID for stable identity - user_id: user.id, - title: task.title, - description: task.description || null, - start_time: task.startTime.toISOString(), - end_time: task.endTime?.toISOString() || null, - duration: task.duration || null, - project_id: project?.id || task.project || null, - project_name: task.project || null, - client: task.client || null, - category_id: task.category || null, - category_name: category?.name || null, - day_record_id: dayId, - is_current: false - // Note: inserted_at and updated_at are NOT included - }; - }); - - const { error: tasksError } = await supabase - .from('tasks') - .upsert(tasksToUpsert, { - onConflict: 'id' - }); - trackDbCall('upsert', 'tasks'); - - if (tasksError) throw tasksError; - } - } - } - - async deleteArchivedDay(dayId: string): Promise { - const user = await getCachedUser(); - - // Delete tasks first (foreign key dependency) - await supabase - .from('tasks') - .delete() - .eq('day_record_id', dayId) - .eq('user_id', user.id); - - // Delete archived day - const { error } = await supabase - .from('archived_days') - .delete() - .eq('id', dayId) - .eq('user_id', user.id); - - if (error) throw error; - } - - async saveProjects(projects: Project[]): Promise { - console.log('🗂️ SupabaseService: Saving projects...', projects.length); - - const user = await getCachedUser(); - - if (projects.length === 0) { - console.log('📭 No projects to save - clearing existing data'); - // Still need to clear existing data if no projects provided - await supabase.from('projects').delete().eq('user_id', user.id); - trackDbCall('delete', 'projects'); - clearDataCaches(); - return; - } - - // Get existing projects to determine what to delete - const { data: existingProjects } = await supabase - .from('projects') - .select('id') - .eq('user_id', user.id); - trackDbCall('select', 'projects'); - - const existingProjectIds = new Set(existingProjects?.map(p => p.id) || []); - const newProjectIds = new Set(projects.map(p => p.id)); - - // Delete projects that no longer exist in local state - const projectsToDelete = Array.from(existingProjectIds).filter(id => !newProjectIds.has(id)); - if (projectsToDelete.length > 0) { - console.log('🗑️ Deleting obsolete projects:', projectsToDelete.length); - const { error: deleteError } = await supabase - .from('projects') - .delete() - .eq('user_id', user.id) - .in('id', projectsToDelete); - trackDbCall('delete', 'projects'); - - if (deleteError) { - console.error('❌ Error deleting obsolete projects:', deleteError); - throw deleteError; - } - } - - // Upsert projects (insert new, update existing) - const projectsToUpsert = projects.map((project) => ({ - id: project.id, - user_id: user.id, - name: project.name, - client: project.client, - hourly_rate: project.hourlyRate || null, - color: project.color || null, - is_billable: project.isBillable !== false // Default to true if not specified - // Note: inserted_at and updated_at are NOT included - })); - - console.log('📝 Upserting projects...'); - const { error } = await supabase - .from('projects') - .upsert(projectsToUpsert, { - onConflict: 'id' - }); - trackDbCall('upsert', 'projects'); - - if (error) { - console.error('❌ Error upserting projects:', error); - throw error; - } - - // Update cache with new data - setCachedProjects(projects); - - console.log('✅ Projects upserted successfully'); - } - - async getProjects(): Promise { - console.log('🗂️ SupabaseService: Loading projects...'); - - // Check cache first - const cachedResult = await getCachedProjects(); - if (cachedResult) { - return cachedResult; - } - - const user = await getCachedUser(); - - const { data, error } = await supabase - .from('projects') - .select('*') - .eq('user_id', user.id) - .order('name', { ascending: true }); - - if (error) { - console.error('❌ Error loading projects:', error); - throw error; - } - - const result = (data || []).map((project) => ({ - id: project.id, - name: project.name, - client: project.client, - hourlyRate: project.hourly_rate || undefined, - color: project.color || undefined, - isBillable: project.is_billable !== false // Default to true if not specified - })); - - // Cache the result - setCachedProjects(result); - - console.log('✅ Projects loaded:', result.length); - return result; - } - - async saveCategories(categories: TaskCategory[]): Promise { - console.log('🏷️ SupabaseService: Saving categories...', categories.length); - - const user = await getCachedUser(); - - if (categories.length === 0) { - console.log('📭 No categories to save - clearing existing data'); - // Still need to clear existing data if no categories provided - await supabase.from('categories').delete().eq('user_id', user.id); - trackDbCall('delete', 'categories'); - clearDataCaches(); - return; - } - - // Get existing categories to determine what to delete - const { data: existingCategories } = await supabase - .from('categories') - .select('id') - .eq('user_id', user.id); - trackDbCall('select', 'categories'); - - const existingCategoryIds = new Set(existingCategories?.map(c => c.id) || []); - const newCategoryIds = new Set(categories.map(c => c.id)); - - // Delete categories that no longer exist in local state - const categoriesToDelete = Array.from(existingCategoryIds).filter(id => !newCategoryIds.has(id)); - if (categoriesToDelete.length > 0) { - console.log('🗑️ Deleting obsolete categories:', categoriesToDelete.length); - const { error: deleteError } = await supabase - .from('categories') - .delete() - .eq('user_id', user.id) - .in('id', categoriesToDelete); - trackDbCall('delete', 'categories'); - - if (deleteError) { - console.error('❌ Error deleting obsolete categories:', deleteError); - throw deleteError; - } - } - - // Upsert categories (insert new, update existing) - const categoriesToUpsert = categories.map((category) => ({ - id: category.id, - user_id: user.id, - name: category.name, - color: category.color || null, - icon: null, // Icon field exists in DB but not in interface yet - is_billable: category.isBillable !== false // Default to true if not specified - // Note: inserted_at and updated_at are NOT included - })); - - console.log('📝 Upserting categories...'); - const { error } = await supabase - .from('categories') - .upsert(categoriesToUpsert, { - onConflict: 'id' - }); - trackDbCall('upsert', 'categories'); - - if (error) { - console.error('❌ Error upserting categories:', error); - throw error; - } - - // Update cache with new data - setCachedCategories(categories); - - console.log('✅ Categories upserted successfully'); - } - - async getCategories(): Promise { - console.log('🏷️ SupabaseService: Loading categories...'); - - // Check cache first - const cachedResult = await getCachedCategories(); - if (cachedResult) { - return cachedResult; - } - - const user = await getCachedUser(); - - const { data, error } = await supabase - .from('categories') - .select('*') - .eq('user_id', user.id) - .order('name', { ascending: true }); - - if (error) { - console.error('❌ Error loading categories:', error); - throw error; - } - - const result = (data || []).map((category) => ({ - id: category.id, - name: category.name, - color: category.color || '#8B5CF6', // Default color if missing - isBillable: category.is_billable !== false // Default to true if not specified - })); - - // Cache the result - setCachedCategories(result); - - console.log('✅ Categories loaded:', result.length); - return result; - } - - async migrateFromLocalStorage(): Promise { - try { - console.log('🔄 Checking for localStorage data to migrate...'); - const localService = new LocalStorageService(); - - // Check if there's actually meaningful data in localStorage before migrating - const projects = await localService.getProjects(); - const categories = await localService.getCategories(); - const currentDay = await localService.getCurrentDay(); - const archivedDays = await localService.getArchivedDays(); - - const hasProjects = projects.length > 0; - const hasCategories = categories.length > 0; - const hasCurrentDay = currentDay && (currentDay.tasks.length > 0 || currentDay.isDayStarted); - const hasArchivedDays = archivedDays.length > 0; - - console.log('📊 localStorage data check:', { - hasProjects, - hasCategories, - hasCurrentDay, - hasArchivedDays, - projectsCount: projects.length, - categoriesCount: categories.length, - currentDayTasks: currentDay?.tasks.length || 0, - archivedDaysCount: archivedDays.length - }); - - // Only migrate if there's substantial data in localStorage - // This prevents overwriting Supabase data with empty localStorage after logout/login - if (!hasProjects && !hasCategories && !hasCurrentDay && !hasArchivedDays) { - console.log('✅ No meaningful localStorage data found - skipping migration'); - return; - } - - // Check if Supabase already has data - if so, be more cautious - const existingCurrentDay = await this.getCurrentDay(); - const existingArchivedDays = await this.getArchivedDays(); - const existingProjects = await this.getProjects(); - - const hasExistingData = ( - (existingCurrentDay && (existingCurrentDay.tasks.length > 0 || existingCurrentDay.isDayStarted)) || - existingArchivedDays.length > 0 || - existingProjects.length > 0 - ); - - if (hasExistingData) { - console.log('⚠️ Supabase already contains data - being cautious with migration'); - - // Only migrate if localStorage has MORE recent or substantial data - const shouldMigrateCurrentDay = hasCurrentDay && (!existingCurrentDay || - (currentDay?.tasks.length ?? 0) > existingCurrentDay.tasks.length); - - const shouldMigrateArchived = hasArchivedDays && - archivedDays.length > existingArchivedDays.length; - - console.log('🔍 Migration decision:', { - shouldMigrateCurrentDay, - shouldMigrateArchived, - localCurrentDayTasks: currentDay?.tasks.length || 0, - existingCurrentDayTasks: existingCurrentDay?.tasks.length || 0, - localArchivedDays: archivedDays.length, - existingArchivedDays: existingArchivedDays.length - }); - - // Only migrate current day if localStorage has more data - if (shouldMigrateCurrentDay) { - console.log('📱 Migrating current day from localStorage (has more data)'); - if (currentDay) { - await this.saveCurrentDay(currentDay); - } else { - console.warn('⚠️ Tried to migrate current day, but currentDay is null or undefined.'); - } - } - - // Only migrate archived days if localStorage has more data - if (shouldMigrateArchived) { - console.log('📚 Migrating archived days from localStorage (has more data)'); - await this.saveArchivedDays(archivedDays); - } - - // Always migrate projects and categories if they exist (they're less likely to conflict) - if (hasProjects) { - console.log('📋 Migrating projects from localStorage'); - await this.saveProjects(projects); - } - - // Be cautious with categories - only migrate if Supabase has no categories - const existingCategories = await this.getCategories(); - if (hasCategories && existingCategories.length === 0) { - console.log('🏷️ Migrating categories from localStorage (Supabase has no categories)'); - await this.saveCategories(categories); - } else if (hasCategories && existingCategories.length > 0) { - console.log('⚠️ Skipping categories migration - Supabase already has categories'); - } - } else { - // No existing data in Supabase, safe to migrate everything - console.log('✅ No existing Supabase data - safe to migrate all localStorage data'); - - if (hasProjects) { - await this.saveProjects(projects); - } - - if (hasCategories) { - await this.saveCategories(categories); - } - - if (hasCurrentDay) { - await this.saveCurrentDay(currentDay); - } - - if (hasArchivedDays) { - await this.saveArchivedDays(archivedDays); - } - } - - console.log('✅ Data migration from localStorage completed safely'); - } catch (error) { - console.error('❌ Error migrating data from localStorage:', error); - } - } - - async migrateToLocalStorage(): Promise { - try { - console.log('🔄 Migrating current data TO localStorage for offline access...'); - const localService = new LocalStorageService(); - - // Get current data from Supabase - const currentDay = await this.getCurrentDay(); - const archivedDays = await this.getArchivedDays(); - const projects = await this.getProjects(); - const categories = await this.getCategories(); - - // Save to localStorage - if (currentDay) { - await localService.saveCurrentDay(currentDay); - console.log('📱 Current day synced to localStorage'); - } - - if (archivedDays.length > 0) { - await localService.saveArchivedDays(archivedDays); - console.log(`📚 ${archivedDays.length} archived days synced to localStorage`); - } - - if (projects.length > 0) { - await localService.saveProjects(projects); - console.log(`📋 ${projects.length} projects synced to localStorage`); - } - - if (categories.length > 0) { - await localService.saveCategories(categories); - console.log(`🏷️ ${categories.length} categories synced to localStorage`); - } - - console.log('✅ Data successfully synced to localStorage for offline access'); - } catch (error) { - console.error('❌ Error migrating data to localStorage:', error); - } - } + // Current day operations + saveCurrentDay: (data: CurrentDayData) => Promise; + getCurrentDay: () => Promise; + + // Archived days operations + saveArchivedDays: (days: DayRecord[]) => Promise; + getArchivedDays: () => Promise; + updateArchivedDay: (id: string, updates: Partial) => Promise; + deleteArchivedDay: (id: string) => Promise; + + // Projects operations + saveProjects: (projects: Project[]) => Promise; + getProjects: () => Promise; + + // Categories operations + saveCategories: (categories: TaskCategory[]) => Promise; + getCategories: () => Promise; + + // Migration operations + migrateFromLocalStorage: () => Promise; + migrateToLocalStorage: () => Promise; } // Factory function to get the appropriate service export const createDataService = (isAuthenticated: boolean): DataService => { - console.log('🔧 Creating data service:', { isAuthenticated }); - return isAuthenticated ? new SupabaseService() : new LocalStorageService(); + console.log("🔧 Creating data service:", { isAuthenticated }); + return isAuthenticated ? new SupabaseService() : new LocalStorageService(); }; diff --git a/src/services/localStorageService.ts b/src/services/localStorageService.ts new file mode 100644 index 0000000..e586b26 --- /dev/null +++ b/src/services/localStorageService.ts @@ -0,0 +1,122 @@ +import { Task, DayRecord, Project } from "@/contexts/TimeTrackingContext"; +import { TaskCategory } from "@/config/categories"; +import { DataService, CurrentDayData } from "@/services/dataService"; + +export const STORAGE_KEYS = { + CURRENT_DAY: "timetracker_current_day", + ARCHIVED_DAYS: "timetracker_archived_days", + PROJECTS: "timetracker_projects", + CATEGORIES: "timetracker_categories" +}; + +export class LocalStorageService implements DataService { + async saveCurrentDay(data: CurrentDayData): Promise { + localStorage.setItem(STORAGE_KEYS.CURRENT_DAY, JSON.stringify(data)); + } + + async getCurrentDay(): Promise { + try { + const saved = localStorage.getItem(STORAGE_KEYS.CURRENT_DAY); + if (!saved) return null; + + const data = JSON.parse(saved); + return { + ...data, + dayStartTime: data.dayStartTime ? new Date(data.dayStartTime) : null, + tasks: data.tasks.map((task: Task) => ({ + ...task, + startTime: new Date(task.startTime), + endTime: task.endTime ? new Date(task.endTime) : undefined + })), + currentTask: data.currentTask + ? { + ...data.currentTask, + startTime: new Date(data.currentTask.startTime), + endTime: data.currentTask.endTime + ? new Date(data.currentTask.endTime) + : undefined + } + : null + }; + } catch (error) { + console.error("Error loading current day from localStorage:", error); + return null; + } + } + + async saveArchivedDays(days: DayRecord[]): Promise { + localStorage.setItem(STORAGE_KEYS.ARCHIVED_DAYS, JSON.stringify(days)); + } + + async getArchivedDays(): Promise { + try { + const saved = localStorage.getItem(STORAGE_KEYS.ARCHIVED_DAYS); + if (!saved) return []; + + const data = JSON.parse(saved); + return data.map((day: DayRecord) => ({ + ...day, + startTime: new Date(day.startTime), + endTime: new Date(day.endTime), + tasks: day.tasks.map((task: Task) => ({ + ...task, + startTime: new Date(task.startTime), + endTime: task.endTime ? new Date(task.endTime) : undefined + })) + })); + } catch (error) { + console.error("Error loading archived days from localStorage:", error); + return []; + } + } + + async updateArchivedDay(dayId: string, updates: Partial): Promise { + const days = await this.getArchivedDays(); + const updatedDays = days.map((day) => + day.id === dayId ? { ...day, ...updates } : day + ); + await this.saveArchivedDays(updatedDays); + } + + async deleteArchivedDay(dayId: string): Promise { + const days = await this.getArchivedDays(); + const filteredDays = days.filter((day) => day.id !== dayId); + await this.saveArchivedDays(filteredDays); + } + + async saveProjects(projects: Project[]): Promise { + localStorage.setItem(STORAGE_KEYS.PROJECTS, JSON.stringify(projects)); + } + + async getProjects(): Promise { + try { + const saved = localStorage.getItem(STORAGE_KEYS.PROJECTS); + return saved ? JSON.parse(saved) : []; + } catch (error) { + console.error("Error loading projects from localStorage:", error); + return []; + } + } + + async saveCategories(categories: TaskCategory[]): Promise { + localStorage.setItem(STORAGE_KEYS.CATEGORIES, JSON.stringify(categories)); + } + + async getCategories(): Promise { + try { + const saved = localStorage.getItem(STORAGE_KEYS.CATEGORIES); + return saved ? JSON.parse(saved) : []; + } catch (error) { + console.error("Error loading categories from localStorage:", error); + return []; + } + } + + async migrateFromLocalStorage(): Promise { + // No-op for localStorage service + } + + async migrateToLocalStorage(): Promise { + // No-op for localStorage service - already in localStorage + } +} diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts new file mode 100644 index 0000000..6ce0459 --- /dev/null +++ b/src/services/supabaseService.ts @@ -0,0 +1,1003 @@ +import { + supabase, + trackDbCall, + getCachedUser, + getCachedProjects, + setCachedProjects, + getCachedCategories, + setCachedCategories, + clearDataCaches, + trackAuthCall +} from "@/lib/supabase"; +import { Task, DayRecord, Project } from "@/contexts/TimeTrackingContext"; +import { TaskCategory } from "@/config/categories"; +import { DataService, CurrentDayData } from "@/services/dataService"; +import { LocalStorageService } from "@/services/localStorageService"; + +export class SupabaseService implements DataService { + // Schema detection with permanent caching + private hasNewSchema: boolean | null = null; + private static schemaChecked: boolean = false; + private static globalSchemaResult: boolean | null = null; + + private async checkNewSchema(): Promise { + // Use global cache first (survives across service instances) + if (SupabaseService.schemaChecked && SupabaseService.globalSchemaResult !== null) { + this.hasNewSchema = SupabaseService.globalSchemaResult; + return this.hasNewSchema; + } + + // Use instance cache + if (this.hasNewSchema !== null) { + return this.hasNewSchema; + } + + try { + // Check if we have a valid authenticated user first + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + trackAuthCall("getUser", "SupabaseService.checkNewSchema"); + + if (userError || !user) { + console.warn("User not authenticated, cannot check schema"); + this.hasNewSchema = false; + SupabaseService.globalSchemaResult = false; + SupabaseService.schemaChecked = true; + return false; + } + + // Try to query the current_day table to see if the new schema exists + const { error } = await supabase + .from("current_day") + .select("user_id") + .eq("user_id", user.id) + .limit(1); + trackDbCall("select", "current_day", "SupabaseService.checkNewSchema"); + + if (error) { + console.warn("New schema not detected:", error.message); + this.hasNewSchema = false; + SupabaseService.globalSchemaResult = false; + SupabaseService.schemaChecked = true; + return false; + } + + this.hasNewSchema = true; + SupabaseService.globalSchemaResult = true; + SupabaseService.schemaChecked = true; + console.log("✅ New schema confirmed and cached globally"); + return true; + } catch (error) { + console.warn("Error checking schema, assuming new schema exists:", error); + this.hasNewSchema = true; + SupabaseService.globalSchemaResult = true; + SupabaseService.schemaChecked = true; + return true; + } + } + + async saveCurrentDay(data: CurrentDayData): Promise { + console.log("💾 SupabaseService: Saving current day...", { + tasksCount: data.tasks.length, + isDayStarted: data.isDayStarted, + hasCurrentTask: !!data.currentTask + }); + + const user = await getCachedUser(); + console.log("👤 User authenticated:", user.id); + + const categories = (await getCachedCategories()) || []; + const projects = (await getCachedProjects()) || []; + + try { + // 1. Save current day state + const { error: currentDayError } = await supabase + .from("current_day") + .upsert({ + user_id: user.id, + is_day_started: data.isDayStarted, + day_start_time: data.dayStartTime?.toISOString(), + current_task_id: data.currentTask?.id || null + }); + trackDbCall("upsert", "current_day"); + + if (currentDayError) { + console.error("❌ Error saving current day state:", currentDayError); + throw currentDayError; + } + + // 2. Handle tasks efficiently + if (data.tasks.length === 0) { + const { error: deleteError } = await supabase + .from("tasks") + .delete() + .eq("user_id", user.id) + .eq("is_current", true); + trackDbCall("delete", "tasks"); + + if (deleteError) { + console.error("❌ Error deleting all current tasks:", deleteError); + throw deleteError; + } + console.log("🗑️ Deleted all current tasks (no tasks provided)"); + } else { + // Get existing task IDs to minimize database operations + const { data: existingTasks } = await supabase + .from("tasks") + .select("id") + .eq("user_id", user.id) + .eq("is_current", true); + trackDbCall("select", "tasks"); + + const existingTaskIds = new Set(existingTasks?.map(t => t.id) || []); + const newTaskIds = new Set(data.tasks.map(t => t.id)); + + // 3. Delete obsolete tasks + const tasksToDelete = Array.from(existingTaskIds).filter(id => !newTaskIds.has(id)); + if (tasksToDelete.length > 0) { + const { error: deleteError } = await supabase + .from("tasks") + .delete() + .eq("user_id", user.id) + .eq("is_current", true) + .in("id", tasksToDelete); + trackDbCall("delete", "tasks"); + + if (deleteError) { + console.error("❌ Error deleting obsolete tasks:", deleteError); + throw deleteError; + } + console.log("🗑️ Deleted obsolete tasks:", tasksToDelete.length); + } + + // 4. Upsert current tasks (single batch operation) + const tasksToUpsert = data.tasks.map((task) => { + const category = categories.find(c => c.id === task.category); + const project = projects.find(p => p.name === task.project); + + return { + id: task.id, + user_id: user.id, + title: task.title, + description: task.description || null, + start_time: task.startTime.toISOString(), + end_time: task.endTime?.toISOString() || null, + duration: task.duration || null, + project_id: project?.id || task.project || null, + project_name: task.project || null, + client: task.client || null, + category_id: task.category || null, + category_name: category?.name || null, + day_record_id: null, + is_current: true + }; + }); + + console.log("📝 Upserting tasks:", tasksToUpsert.length); + + const { error: tasksError } = await supabase + .from("tasks") + .upsert(tasksToUpsert, { onConflict: "id" }); + trackDbCall("upsert", "tasks"); + + if (tasksError) { + console.error("❌ Error upserting tasks:", tasksError); + throw tasksError; + } + + console.log("✅ Tasks upserted successfully"); + } + + console.log("✅ Current day saved successfully"); + } catch (error) { + console.error("❌ Error in saveCurrentDay:", error); + throw error; + } + } + + async getCurrentDay(): Promise { + console.log("🔄 SupabaseService: Loading current day..."); + + const user = await getCachedUser(); + console.log("👤 User authenticated:", user.id); + + const { data: currentDayData, error: currentDayError } = await supabase + .from("current_day") + .select("*") + .eq("user_id", user.id) + .single(); + + if (currentDayError && currentDayError.code !== "PGRST116") { + console.error("❌ Error loading current day state:", currentDayError); + throw currentDayError; + } + + const { data: tasksData, error: tasksError } = await supabase + .from("tasks") + .select("*") + .eq("user_id", user.id) + .eq("is_current", true) + .order("start_time", { ascending: true }); + + if (tasksError) { + console.error("❌ Error loading current tasks:", tasksError); + throw tasksError; + } + + console.log("📊 Loaded data:", { + hasDayData: !!currentDayData, + tasksCount: tasksData?.length || 0 + }); + + if (!currentDayData && (!tasksData || tasksData.length === 0)) { + console.log("📭 No current day data found"); + return null; + } + + const tasks: Task[] = (tasksData || []).map((task) => ({ + id: task.id, + title: task.title, + description: task.description || undefined, + startTime: new Date(task.start_time), + endTime: task.end_time ? new Date(task.end_time) : undefined, + duration: task.duration || undefined, + project: task.project_name || undefined, + client: task.client || undefined, + category: task.category_id || undefined, + insertedAt: task.inserted_at ? new Date(task.inserted_at) : undefined, + updatedAt: task.updated_at ? new Date(task.updated_at) : undefined + })); + + const currentTask = currentDayData?.current_task_id + ? tasks.find((task) => task.id === currentDayData.current_task_id) || null + : null; + + const result = { + isDayStarted: currentDayData?.is_day_started || false, + dayStartTime: currentDayData?.day_start_time + ? new Date(currentDayData.day_start_time) + : null, + tasks, + currentTask + }; + + console.log("✅ Current day loaded:", { + isDayStarted: result.isDayStarted, + tasksCount: result.tasks.length, + hasCurrentTask: !!result.currentTask + }); + + return result; + } + + async saveArchivedDays(days: DayRecord[]): Promise { + console.log("📁 SupabaseService: Saving archived days...", days.length); + + const user = await getCachedUser(); + console.log("👤 User authenticated:", user.id); + + const categories = (await getCachedCategories()) || []; + const projects = (await getCachedProjects()) || []; + + if (!user.id) { + throw new Error("User ID is null - cannot save archived data"); + } + + if (days.length === 0) { + console.log("📭 No archived days to save - clearing existing data"); + await supabase.from("tasks").delete().eq("user_id", user.id).eq("is_current", false); + await supabase.from("archived_days").delete().eq("user_id", user.id); + return; + } + + const archivedDaysToUpsert = days.map((day) => ({ + id: day.id, + user_id: user.id, + date: day.date, + total_duration: day.totalDuration, + start_time: day.startTime.toISOString(), + end_time: day.endTime.toISOString(), + notes: day.notes + })); + + const allTasks = days.flatMap((day) => + day.tasks.map((task) => { + const category = categories.find(c => c.id === task.category); + const project = projects.find(p => p.name === task.project); + + return { + id: task.id, + user_id: user.id, + title: task.title, + description: task.description || null, + start_time: task.startTime.toISOString(), + end_time: task.endTime?.toISOString() || null, + duration: task.duration || null, + project_id: project?.id || task.project || null, + project_name: task.project || null, + client: task.client || null, + category_id: task.category || null, + category_name: category?.name || null, + day_record_id: day.id, + is_current: false + }; + }) + ); + + console.log("🔄 Prepared data - Days:", archivedDaysToUpsert.length, "Tasks:", allTasks.length); + + try { + // Step 1: Get existing data to determine what to delete + const { data: existingDays } = await supabase + .from("archived_days") + .select("id") + .eq("user_id", user.id); + trackDbCall("select", "archived_days"); + + const { data: existingTasks } = await supabase + .from("tasks") + .select("id") + .eq("user_id", user.id) + .eq("is_current", false); + trackDbCall("select", "tasks"); + + const existingDayIds = new Set(existingDays?.map(d => d.id) || []); + const newDayIds = new Set(days.map(d => d.id)); + const existingTaskIds = new Set(existingTasks?.map(t => t.id) || []); + const newTaskIds = new Set(allTasks.map(t => t.id)); + + // Step 2: Delete archived days that no longer exist in local state + const daysToDelete = Array.from(existingDayIds).filter(id => !newDayIds.has(id)); + if (daysToDelete.length > 0) { + console.log("🗑️ Deleting obsolete archived days:", daysToDelete.length); + const { error: deleteDaysError } = await supabase + .from("archived_days") + .delete() + .eq("user_id", user.id) + .in("id", daysToDelete); + trackDbCall("delete", "archived_days"); + + if (deleteDaysError) { + console.error("❌ Error deleting obsolete days:", deleteDaysError); + throw deleteDaysError; + } + } + + // Step 3: Delete archived tasks that no longer exist in local state + const tasksToDelete = Array.from(existingTaskIds).filter(id => !newTaskIds.has(id)); + if (tasksToDelete.length > 0) { + console.log("🗑️ Deleting obsolete archived tasks:", tasksToDelete.length); + const { error: deleteTasksError } = await supabase + .from("tasks") + .delete() + .eq("user_id", user.id) + .eq("is_current", false) + .in("id", tasksToDelete); + trackDbCall("delete", "tasks"); + + if (deleteTasksError) { + console.error("❌ Error deleting obsolete tasks:", deleteTasksError); + throw deleteTasksError; + } + } + + // Step 4: Upsert archived days + console.log("📅 Upserting archived days..."); + const { error: daysError } = await supabase + .from("archived_days") + .upsert(archivedDaysToUpsert, { onConflict: "id" }); + trackDbCall("upsert", "archived_days"); + + if (daysError) { + console.error("❌ Error upserting archived days:", daysError); + throw daysError; + } + + console.log("✅ Archived days upserted successfully"); + + // Step 5: Upsert archived tasks + if (allTasks.length > 0) { + console.log("📝 Upserting archived tasks...", allTasks.length); + + const { error: tasksError } = await supabase + .from("tasks") + .upsert(allTasks, { onConflict: "id" }); + trackDbCall("upsert", "tasks"); + + if (tasksError) { + console.error("❌ Error upserting archived tasks:", tasksError); + console.error("❌ Failed task data sample:", allTasks[0]); + throw tasksError; + } + + console.log("✅ Archived tasks upserted successfully"); + } + + console.log("🎉 All archived data saved successfully"); + + console.log("🔍 Verifying archived data was saved correctly..."); + await this.verifyArchivedDataIntegrity(days); + } catch (error) { + console.error("💥 Archiving save failed:", error); + console.error("🚨 Data that failed to save:", { + daysCount: archivedDaysToUpsert.length, + tasksCount: allTasks.length, + firstDay: archivedDaysToUpsert[0]?.date, + error: error + }); + throw error; + } + } + + private async verifyArchivedDataIntegrity(expectedDays: DayRecord[]): Promise { + try { + const user = await getCachedUser(); + + const { data: savedDays, error: daysError } = await supabase + .from("archived_days") + .select("id") + .eq("user_id", user.id); + + if (daysError) { + console.warn("⚠️ Could not verify archived days:", daysError); + return; + } + + const { data: savedTasks, error: tasksError } = await supabase + .from("tasks") + .select("id") + .eq("user_id", user.id) + .eq("is_current", false); + + if (tasksError) { + console.warn("⚠️ Could not verify archived tasks:", tasksError); + return; + } + + const expectedTasksCount = expectedDays.reduce((sum, day) => sum + day.tasks.length, 0); + + console.log("📊 Archive verification results:", { + expectedDays: expectedDays.length, + savedDays: savedDays?.length || 0, + expectedTasks: expectedTasksCount, + savedTasks: savedTasks?.length || 0 + }); + + if (savedDays?.length !== expectedDays.length) { + console.error("❌ Archive verification failed: Day count mismatch"); + } + + if (savedTasks?.length !== expectedTasksCount) { + console.error("❌ Archive verification failed: Task count mismatch"); + throw new Error( + `Archive verification failed: Expected ${expectedTasksCount} tasks, found ${savedTasks?.length}` + ); + } + + console.log("✅ Archive verification passed"); + } catch (error) { + console.error("❌ Archive verification failed:", error); + throw error; + } + } + + async getArchivedDays(): Promise { + console.log("📁 SupabaseService: Loading archived days..."); + + const user = await getCachedUser(); + console.log("👤 User authenticated:", user.id); + + const { data: daysData, error: daysError } = await supabase + .from("archived_days") + .select("*") + .eq("user_id", user.id) + .order("start_time", { ascending: false }); + + if (daysError) { + console.error("❌ Error loading archived days:", daysError); + throw daysError; + } + + if (!daysData || daysData.length === 0) { + console.log("📭 No archived days found"); + return []; + } + + console.log("📊 Found archived days:", daysData.length); + + const { data: tasksData, error: tasksError } = await supabase + .from("tasks") + .select("*") + .eq("user_id", user.id) + .eq("is_current", false) + .order("start_time", { ascending: true }); + + if (tasksError) { + console.error("❌ Error loading archived tasks:", tasksError); + throw tasksError; + } + + console.log("📝 Found archived tasks:", tasksData?.length || 0); + + const tasksByDay: Record = {}; + (tasksData || []).forEach((task) => { + if (!task.day_record_id) return; + + if (!tasksByDay[task.day_record_id]) { + tasksByDay[task.day_record_id] = []; + } + + tasksByDay[task.day_record_id].push({ + id: task.id, + title: task.title, + description: task.description || undefined, + startTime: new Date(task.start_time), + endTime: task.end_time ? new Date(task.end_time) : undefined, + duration: task.duration || undefined, + project: task.project_name || undefined, + client: task.client || undefined, + category: task.category_id || undefined, + insertedAt: task.inserted_at ? new Date(task.inserted_at) : undefined, + updatedAt: task.updated_at ? new Date(task.updated_at) : undefined + }); + }); + + const result = daysData.map((day) => ({ + id: day.id, + date: day.date, + tasks: tasksByDay[day.id] || [], + totalDuration: day.total_duration, + startTime: new Date(day.start_time), + endTime: new Date(day.end_time), + notes: day.notes, + insertedAt: day.inserted_at ? new Date(day.inserted_at) : undefined, + updatedAt: day.updated_at ? new Date(day.updated_at) : undefined + })); + + console.log("✅ Archived days loaded:", { + daysCount: result.length, + totalTasks: result.reduce((sum, day) => sum + day.tasks.length, 0) + }); + + return result; + } + + async updateArchivedDay(dayId: string, updates: Partial): Promise { + const user = await getCachedUser(); + + const categories = await getCachedCategories(); + const projects = await getCachedProjects(); + + const updateData: Record = {}; + + if (updates.date) updateData.date = updates.date; + if (updates.totalDuration !== undefined) updateData.total_duration = updates.totalDuration; + if (updates.startTime) updateData.start_time = updates.startTime.toISOString(); + if (updates.endTime) updateData.end_time = updates.endTime.toISOString(); + if (updates.notes !== undefined) updateData.notes = updates.notes; + + const { error: dayError } = await supabase + .from("archived_days") + .update(updateData) + .eq("id", dayId) + .eq("user_id", user.id); + trackDbCall("update", "archived_days"); + + if (dayError) throw dayError; + + if (updates.tasks) { + const { data: existingTasks } = await supabase + .from("tasks") + .select("id") + .eq("day_record_id", dayId) + .eq("user_id", user.id) + .eq("is_current", false); + trackDbCall("select", "tasks"); + + const existingTaskIds = new Set(existingTasks?.map(t => t.id) || []); + const newTaskIds = new Set(updates.tasks.map(t => t.id)); + + const tasksToDelete = Array.from(existingTaskIds).filter(id => !newTaskIds.has(id)); + if (tasksToDelete.length > 0) { + const { error: deleteError } = await supabase + .from("tasks") + .delete() + .eq("day_record_id", dayId) + .eq("user_id", user.id) + .eq("is_current", false) + .in("id", tasksToDelete); + trackDbCall("delete", "tasks"); + + if (deleteError) throw deleteError; + } + + if (updates.tasks.length > 0) { + const tasksToUpsert = updates.tasks.map((task) => { + const category = categories.find(c => c.id === task.category); + const project = projects.find(p => p.name === task.project); + + return { + id: task.id, + user_id: user.id, + title: task.title, + description: task.description || null, + start_time: task.startTime.toISOString(), + end_time: task.endTime?.toISOString() || null, + duration: task.duration || null, + project_id: project?.id || task.project || null, + project_name: task.project || null, + client: task.client || null, + category_id: task.category || null, + category_name: category?.name || null, + day_record_id: dayId, + is_current: false + }; + }); + + const { error: tasksError } = await supabase + .from("tasks") + .upsert(tasksToUpsert, { onConflict: "id" }); + trackDbCall("upsert", "tasks"); + + if (tasksError) throw tasksError; + } + } + } + + async deleteArchivedDay(dayId: string): Promise { + const user = await getCachedUser(); + + // Delete tasks first (foreign key dependency) + await supabase + .from("tasks") + .delete() + .eq("day_record_id", dayId) + .eq("user_id", user.id); + + const { error } = await supabase + .from("archived_days") + .delete() + .eq("id", dayId) + .eq("user_id", user.id); + + if (error) throw error; + } + + async saveProjects(projects: Project[]): Promise { + console.log("🗂️ SupabaseService: Saving projects...", projects.length); + + const user = await getCachedUser(); + + if (projects.length === 0) { + console.log("📭 No projects to save - clearing existing data"); + await supabase.from("projects").delete().eq("user_id", user.id); + trackDbCall("delete", "projects"); + clearDataCaches(); + return; + } + + const { data: existingProjects } = await supabase + .from("projects") + .select("id") + .eq("user_id", user.id); + trackDbCall("select", "projects"); + + const existingProjectIds = new Set(existingProjects?.map(p => p.id) || []); + const newProjectIds = new Set(projects.map(p => p.id)); + + const projectsToDelete = Array.from(existingProjectIds).filter( + id => !newProjectIds.has(id) + ); + if (projectsToDelete.length > 0) { + console.log("🗑️ Deleting obsolete projects:", projectsToDelete.length); + const { error: deleteError } = await supabase + .from("projects") + .delete() + .eq("user_id", user.id) + .in("id", projectsToDelete); + trackDbCall("delete", "projects"); + + if (deleteError) { + console.error("❌ Error deleting obsolete projects:", deleteError); + throw deleteError; + } + } + + const projectsToUpsert = projects.map((project) => ({ + id: project.id, + user_id: user.id, + name: project.name, + client: project.client, + hourly_rate: project.hourlyRate || null, + color: project.color || null, + is_billable: project.isBillable !== false + })); + + console.log("📝 Upserting projects..."); + const { error } = await supabase + .from("projects") + .upsert(projectsToUpsert, { onConflict: "id" }); + trackDbCall("upsert", "projects"); + + if (error) { + console.error("❌ Error upserting projects:", error); + throw error; + } + + setCachedProjects(projects); + console.log("✅ Projects upserted successfully"); + } + + async getProjects(): Promise { + console.log("🗂️ SupabaseService: Loading projects..."); + + const cachedResult = await getCachedProjects(); + if (cachedResult) { + return cachedResult; + } + + const user = await getCachedUser(); + + const { data, error } = await supabase + .from("projects") + .select("*") + .eq("user_id", user.id) + .order("name", { ascending: true }); + + if (error) { + console.error("❌ Error loading projects:", error); + throw error; + } + + const result = (data || []).map((project) => ({ + id: project.id, + name: project.name, + client: project.client, + hourlyRate: project.hourly_rate || undefined, + color: project.color || undefined, + isBillable: project.is_billable !== false + })); + + setCachedProjects(result); + console.log("✅ Projects loaded:", result.length); + return result; + } + + async saveCategories(categories: TaskCategory[]): Promise { + console.log("🏷️ SupabaseService: Saving categories...", categories.length); + + const user = await getCachedUser(); + + if (categories.length === 0) { + console.log("📭 No categories to save - clearing existing data"); + await supabase.from("categories").delete().eq("user_id", user.id); + trackDbCall("delete", "categories"); + clearDataCaches(); + return; + } + + const { data: existingCategories } = await supabase + .from("categories") + .select("id") + .eq("user_id", user.id); + trackDbCall("select", "categories"); + + const existingCategoryIds = new Set(existingCategories?.map(c => c.id) || []); + const newCategoryIds = new Set(categories.map(c => c.id)); + + const categoriesToDelete = Array.from(existingCategoryIds).filter( + id => !newCategoryIds.has(id) + ); + if (categoriesToDelete.length > 0) { + console.log("🗑️ Deleting obsolete categories:", categoriesToDelete.length); + const { error: deleteError } = await supabase + .from("categories") + .delete() + .eq("user_id", user.id) + .in("id", categoriesToDelete); + trackDbCall("delete", "categories"); + + if (deleteError) { + console.error("❌ Error deleting obsolete categories:", deleteError); + throw deleteError; + } + } + + const categoriesToUpsert = categories.map((category) => ({ + id: category.id, + user_id: user.id, + name: category.name, + color: category.color || null, + icon: null, + is_billable: category.isBillable !== false + })); + + console.log("📝 Upserting categories..."); + const { error } = await supabase + .from("categories") + .upsert(categoriesToUpsert, { onConflict: "id" }); + trackDbCall("upsert", "categories"); + + if (error) { + console.error("❌ Error upserting categories:", error); + throw error; + } + + setCachedCategories(categories); + console.log("✅ Categories upserted successfully"); + } + + async getCategories(): Promise { + console.log("🏷️ SupabaseService: Loading categories..."); + + const cachedResult = await getCachedCategories(); + if (cachedResult) { + return cachedResult; + } + + const user = await getCachedUser(); + + const { data, error } = await supabase + .from("categories") + .select("*") + .eq("user_id", user.id) + .order("name", { ascending: true }); + + if (error) { + console.error("❌ Error loading categories:", error); + throw error; + } + + const result = (data || []).map((category) => ({ + id: category.id, + name: category.name, + color: category.color || "#8B5CF6", + isBillable: category.is_billable !== false + })); + + setCachedCategories(result); + console.log("✅ Categories loaded:", result.length); + return result; + } + + async migrateFromLocalStorage(): Promise { + try { + console.log("🔄 Checking for localStorage data to migrate..."); + const localService = new LocalStorageService(); + + const projects = await localService.getProjects(); + const categories = await localService.getCategories(); + const currentDay = await localService.getCurrentDay(); + const archivedDays = await localService.getArchivedDays(); + + const hasProjects = projects.length > 0; + const hasCategories = categories.length > 0; + const hasCurrentDay = + currentDay && (currentDay.tasks.length > 0 || currentDay.isDayStarted); + const hasArchivedDays = archivedDays.length > 0; + + console.log("📊 localStorage data check:", { + hasProjects, + hasCategories, + hasCurrentDay, + hasArchivedDays, + projectsCount: projects.length, + categoriesCount: categories.length, + currentDayTasks: currentDay?.tasks.length || 0, + archivedDaysCount: archivedDays.length + }); + + if (!hasProjects && !hasCategories && !hasCurrentDay && !hasArchivedDays) { + console.log("✅ No meaningful localStorage data found - skipping migration"); + return; + } + + const existingCurrentDay = await this.getCurrentDay(); + const existingArchivedDays = await this.getArchivedDays(); + const existingProjects = await this.getProjects(); + + const hasExistingData = + (existingCurrentDay && + (existingCurrentDay.tasks.length > 0 || existingCurrentDay.isDayStarted)) || + existingArchivedDays.length > 0 || + existingProjects.length > 0; + + if (hasExistingData) { + console.log("⚠️ Supabase already contains data - being cautious with migration"); + + const shouldMigrateCurrentDay = + hasCurrentDay && + (!existingCurrentDay || + (currentDay?.tasks.length ?? 0) > existingCurrentDay.tasks.length); + + const shouldMigrateArchived = + hasArchivedDays && archivedDays.length > existingArchivedDays.length; + + console.log("🔍 Migration decision:", { + shouldMigrateCurrentDay, + shouldMigrateArchived, + localCurrentDayTasks: currentDay?.tasks.length || 0, + existingCurrentDayTasks: existingCurrentDay?.tasks.length || 0, + localArchivedDays: archivedDays.length, + existingArchivedDays: existingArchivedDays.length + }); + + if (shouldMigrateCurrentDay) { + console.log("📱 Migrating current day from localStorage (has more data)"); + if (currentDay) { + await this.saveCurrentDay(currentDay); + } else { + console.warn("⚠️ Tried to migrate current day, but currentDay is null or undefined."); + } + } + + if (shouldMigrateArchived) { + console.log("📚 Migrating archived days from localStorage (has more data)"); + await this.saveArchivedDays(archivedDays); + } + + if (hasProjects) { + console.log("📋 Migrating projects from localStorage"); + await this.saveProjects(projects); + } + + const existingCategories = await this.getCategories(); + if (hasCategories && existingCategories.length === 0) { + console.log("🏷️ Migrating categories from localStorage (Supabase has no categories)"); + await this.saveCategories(categories); + } else if (hasCategories && existingCategories.length > 0) { + console.log("⚠️ Skipping categories migration - Supabase already has categories"); + } + } else { + console.log("✅ No existing Supabase data - safe to migrate all localStorage data"); + + if (hasProjects) await this.saveProjects(projects); + if (hasCategories) await this.saveCategories(categories); + if (hasCurrentDay) await this.saveCurrentDay(currentDay); + if (hasArchivedDays) await this.saveArchivedDays(archivedDays); + } + + console.log("✅ Data migration from localStorage completed safely"); + } catch (error) { + console.error("❌ Error migrating data from localStorage:", error); + } + } + + async migrateToLocalStorage(): Promise { + try { + console.log("🔄 Migrating current data TO localStorage for offline access..."); + const localService = new LocalStorageService(); + + const currentDay = await this.getCurrentDay(); + const archivedDays = await this.getArchivedDays(); + const projects = await this.getProjects(); + const categories = await this.getCategories(); + + if (currentDay) { + await localService.saveCurrentDay(currentDay); + console.log("📱 Current day synced to localStorage"); + } + + if (archivedDays.length > 0) { + await localService.saveArchivedDays(archivedDays); + console.log(`📚 ${archivedDays.length} archived days synced to localStorage`); + } + + if (projects.length > 0) { + await localService.saveProjects(projects); + console.log(`📋 ${projects.length} projects synced to localStorage`); + } + + if (categories.length > 0) { + await localService.saveCategories(categories); + console.log(`🏷️ ${categories.length} categories synced to localStorage`); + } + + console.log("✅ Data successfully synced to localStorage for offline access"); + } catch (error) { + console.error("❌ Error migrating data to localStorage:", error); + } + } +} From 9c4feb7d58e9aa35aeb29be84a611cb207cf1f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 19:47:52 +0000 Subject: [PATCH 02/14] refactor: extract calculation utilities from TimeTrackingContext Move all billing/duration calculation functions into a dedicated calculationUtils.ts as pure functions, removing ~100 lines from TimeTrackingContext. Context methods now delegate to these utilities. https://claude.ai/code/session_012AmNjF3Ju9VJwSWybJqBEw --- src/contexts/TimeTrackingContext.tsx | 163 ++++----------------------- src/utils/calculationUtils.ts | 137 ++++++++++++++++++++++ 2 files changed, 157 insertions(+), 143 deletions(-) create mode 100644 src/utils/calculationUtils.ts diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 22865cd..01d5880 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -13,6 +13,14 @@ import { createDataService, DataService } from '@/services/dataService'; import { useRealtimeSync } from '@/hooks/useRealtimeSync'; import { generateDailySummary } from '@/utils/timeUtil'; import { toast } from '@/hooks/use-toast'; +import { + getHoursWorkedForDay as calcHoursWorkedForDay, + getRevenueForDay as calcRevenueForDay, + getBillableHoursForDay as calcBillableHoursForDay, + getNonBillableHoursForDay as calcNonBillableHoursForDay, + getTotalHoursForPeriod as calcTotalHoursForPeriod, + getRevenueForPeriod as calcRevenueForPeriod +} from '@/utils/calculationUtils'; export interface Task { id: string; @@ -895,154 +903,23 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ return currentTime.getTime() - currentTask.startTime.getTime(); }; - const getTotalHoursForPeriod = (startDate: Date, endDate: Date): number => { - const filteredDays = archivedDays.filter(day => { - const dayDate = new Date(day.startTime); - return dayDate >= startDate && dayDate <= endDate; - }); - - const totalMs = filteredDays.reduce( - (total, day) => total + day.totalDuration, - 0 - ); - return Math.round((totalMs / (1000 * 60 * 60)) * 100) / 100; - }; - - const getRevenueForPeriod = (startDate: Date, endDate: Date): number => { - const filteredDays = archivedDays.filter(day => { - const dayDate = new Date(day.startTime); - return dayDate >= startDate && dayDate <= endDate; - }); - - // Create lookup maps for O(1) access (performance optimization) - const projectMap = new Map(projects.map(p => [p.name, p])); - const categoryMap = new Map(categories.map(c => [c.id, c])); - - let totalRevenue = 0; - filteredDays.forEach(day => { - day.tasks.forEach(task => { - if (task.project && task.duration && task.category) { - // Check if both the project and category are billable - const project = projectMap.get(task.project); - const category = categoryMap.get(task.category); - - const projectIsBillable = project?.isBillable !== false; // Default to billable if not specified - const categoryIsBillable = category?.isBillable !== false; // Default to billable if not specified - - // Task is billable only if BOTH project AND category are billable - const isBillable = projectIsBillable && categoryIsBillable; - - if (isBillable && project?.hourlyRate) { - const hours = task.duration / (1000 * 60 * 60); - totalRevenue += hours * project.hourlyRate; - } - } - }); - }); - - return Math.round(totalRevenue * 100) / 100; - }; - - const getHoursWorkedForDay = (day: DayRecord): number => { - // Calculate total time worked (sum of all task durations, excluding breaks) - let totalTaskDuration = 0; - day.tasks.forEach(task => { - if (task.duration) { - totalTaskDuration += task.duration; - } - }); - - // Convert milliseconds to hours - const hours = totalTaskDuration / (1000 * 60 * 60); - return Math.round(hours * 100) / 100; - }; + const getTotalHoursForPeriod = (startDate: Date, endDate: Date): number => + calcTotalHoursForPeriod(archivedDays, startDate, endDate); - const getRevenueForDay = (day: DayRecord): number => { - // Create lookup maps for O(1) access (performance optimization) - const projectMap = new Map(projects.map(p => [p.name, p])); - const categoryMap = new Map(categories.map(c => [c.id, c])); - - let totalRevenue = 0; - - day.tasks.forEach(task => { - if (task.project && task.duration && task.category) { - // Check if both the project and category are billable - const project = projectMap.get(task.project); - const category = categoryMap.get(task.category); - - const projectIsBillable = project?.isBillable !== false; // Default to billable if not specified - const categoryIsBillable = category?.isBillable !== false; // Default to billable if not specified - - // Task is billable only if BOTH project AND category are billable - const isBillable = projectIsBillable && categoryIsBillable; - - if (isBillable && project?.hourlyRate) { - const hours = task.duration / (1000 * 60 * 60); - const revenue = hours * project.hourlyRate; - totalRevenue += revenue; - } - } - }); - - return Math.round(totalRevenue * 100) / 100; - }; - const getBillableHoursForDay = (day: DayRecord): number => { - // Create lookup maps for O(1) access (performance optimization) - const projectMap = new Map(projects.map(p => [p.name, p])); - const categoryMap = new Map(categories.map(c => [c.id, c])); - - let billableTime = 0; - day.tasks.forEach(task => { - if (task.duration && task.category && task.project) { - // Check if both the project and category are billable - const project = projectMap.get(task.project); - const category = categoryMap.get(task.category); + const getRevenueForPeriod = (startDate: Date, endDate: Date): number => + calcRevenueForPeriod(archivedDays, projects, categories, startDate, endDate); - const projectIsBillable = project?.isBillable !== false; // Default to billable if not specified - const categoryIsBillable = category?.isBillable !== false; // Default to billable if not specified + const getHoursWorkedForDay = (day: DayRecord): number => + calcHoursWorkedForDay(day); - // Task is billable only if BOTH project AND category are billable - const isBillable = projectIsBillable && categoryIsBillable; + const getRevenueForDay = (day: DayRecord): number => + calcRevenueForDay(day, projects, categories); - if (isBillable) { - billableTime += task.duration; - } - } - }); - - // Convert milliseconds to hours - const hours = billableTime / (1000 * 60 * 60); - return Math.round(hours * 100) / 100; - }; - - const getNonBillableHoursForDay = (day: DayRecord): number => { - // Create lookup maps for O(1) access (performance optimization) - const projectMap = new Map(projects.map(p => [p.name, p])); - const categoryMap = new Map(categories.map(c => [c.id, c])); + const getBillableHoursForDay = (day: DayRecord): number => + calcBillableHoursForDay(day, projects, categories); - let nonBillableTime = 0; - day.tasks.forEach(task => { - if (task.duration && task.category && task.project) { - // Check if both the project and category are billable - const project = projectMap.get(task.project); - const category = categoryMap.get(task.category); - - const projectIsBillable = project?.isBillable !== false; // Default to billable if not specified - const categoryIsBillable = category?.isBillable !== false; // Default to billable if not specified - - // Task is billable only if BOTH project AND category are billable - const isBillable = projectIsBillable && categoryIsBillable; - - if (!isBillable) { - nonBillableTime += task.duration; - } - } - }); - - // Convert milliseconds to hours - const hours = nonBillableTime / (1000 * 60 * 60); - return Math.round(hours * 100) / 100; - }; + const getNonBillableHoursForDay = (day: DayRecord): number => + calcNonBillableHoursForDay(day, projects, categories); const exportToCSV = (startDate?: Date, endDate?: Date): string => { let filteredDays = archivedDays; diff --git a/src/utils/calculationUtils.ts b/src/utils/calculationUtils.ts new file mode 100644 index 0000000..57f4570 --- /dev/null +++ b/src/utils/calculationUtils.ts @@ -0,0 +1,137 @@ +import { Task, DayRecord, Project } from "@/contexts/TimeTrackingContext"; +import { TaskCategory } from "@/config/categories"; + +/** + * Returns true if a task is billable based on its project and category settings. + */ +function isTaskBillable( + task: Task, + projectMap: Map, + categoryMap: Map +): boolean { + if (!task.project || !task.category) return false; + const project = projectMap.get(task.project); + const category = categoryMap.get(task.category); + const projectIsBillable = project?.isBillable !== false; + const categoryIsBillable = category?.isBillable !== false; + return projectIsBillable && categoryIsBillable; +} + +export function getHoursWorkedForDay(day: DayRecord): number { + let totalTaskDuration = 0; + day.tasks.forEach(task => { + if (task.duration) totalTaskDuration += task.duration; + }); + return Math.round((totalTaskDuration / (1000 * 60 * 60)) * 100) / 100; +} + +export function getRevenueForDay( + day: DayRecord, + projects: Project[], + categories: TaskCategory[] +): number { + const projectMap = new Map(projects.map(p => [p.name, p])); + const categoryMap = new Map(categories.map(c => [c.id, c])); + + let totalRevenue = 0; + day.tasks.forEach(task => { + if (task.project && task.duration && task.category) { + if (isTaskBillable(task, projectMap, categoryMap)) { + const project = projectMap.get(task.project); + if (project?.hourlyRate) { + const hours = task.duration / (1000 * 60 * 60); + totalRevenue += hours * project.hourlyRate; + } + } + } + }); + + return Math.round(totalRevenue * 100) / 100; +} + +export function getBillableHoursForDay( + day: DayRecord, + projects: Project[], + categories: TaskCategory[] +): number { + const projectMap = new Map(projects.map(p => [p.name, p])); + const categoryMap = new Map(categories.map(c => [c.id, c])); + + let billableTime = 0; + day.tasks.forEach(task => { + if (task.duration && task.category && task.project) { + if (isTaskBillable(task, projectMap, categoryMap)) { + billableTime += task.duration; + } + } + }); + + return Math.round((billableTime / (1000 * 60 * 60)) * 100) / 100; +} + +export function getNonBillableHoursForDay( + day: DayRecord, + projects: Project[], + categories: TaskCategory[] +): number { + const projectMap = new Map(projects.map(p => [p.name, p])); + const categoryMap = new Map(categories.map(c => [c.id, c])); + + let nonBillableTime = 0; + day.tasks.forEach(task => { + if (task.duration && task.category && task.project) { + if (!isTaskBillable(task, projectMap, categoryMap)) { + nonBillableTime += task.duration; + } + } + }); + + return Math.round((nonBillableTime / (1000 * 60 * 60)) * 100) / 100; +} + +export function getTotalHoursForPeriod( + archivedDays: DayRecord[], + startDate: Date, + endDate: Date +): number { + const filteredDays = archivedDays.filter(day => { + const dayDate = new Date(day.startTime); + return dayDate >= startDate && dayDate <= endDate; + }); + + const totalMs = filteredDays.reduce((total, day) => total + day.totalDuration, 0); + return Math.round((totalMs / (1000 * 60 * 60)) * 100) / 100; +} + +export function getRevenueForPeriod( + archivedDays: DayRecord[], + projects: Project[], + categories: TaskCategory[], + startDate: Date, + endDate: Date +): number { + const filteredDays = archivedDays.filter(day => { + const dayDate = new Date(day.startTime); + return dayDate >= startDate && dayDate <= endDate; + }); + + const projectMap = new Map(projects.map(p => [p.name, p])); + const categoryMap = new Map(categories.map(c => [c.id, c])); + + let totalRevenue = 0; + filteredDays.forEach(day => { + day.tasks.forEach(task => { + if (task.project && task.duration && task.category) { + if (isTaskBillable(task, projectMap, categoryMap)) { + const project = projectMap.get(task.project); + if (project?.hourlyRate) { + const hours = task.duration / (1000 * 60 * 60); + totalRevenue += hours * project.hourlyRate; + } + } + } + }); + }); + + return Math.round(totalRevenue * 100) / 100; +} From dbac2237e33218b0aa2e4f2fba7fe044114de371 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 19:49:57 +0000 Subject: [PATCH 03/14] refactor: extract export and invoice utilities from TimeTrackingContext Move exportToCSV, exportToJSON, generateInvoiceData, and CSV parsing logic into exportUtils.ts as pure functions, removing ~350 lines from TimeTrackingContext. Context methods now delegate to these utilities. https://claude.ai/code/session_012AmNjF3Ju9VJwSWybJqBEw --- src/contexts/TimeTrackingContext.tsx | 439 ++------------------------- src/utils/exportUtils.ts | 394 ++++++++++++++++++++++++ 2 files changed, 412 insertions(+), 421 deletions(-) create mode 100644 src/utils/exportUtils.ts diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx index 01d5880..7c8c685 100644 --- a/src/contexts/TimeTrackingContext.tsx +++ b/src/contexts/TimeTrackingContext.tsx @@ -11,7 +11,6 @@ import { DEFAULT_PROJECTS, ProjectCategory } from '@/config/projects'; import { useAuth } from '@/hooks/useAuth'; import { createDataService, DataService } from '@/services/dataService'; import { useRealtimeSync } from '@/hooks/useRealtimeSync'; -import { generateDailySummary } from '@/utils/timeUtil'; import { toast } from '@/hooks/use-toast'; import { getHoursWorkedForDay as calcHoursWorkedForDay, @@ -21,6 +20,12 @@ import { getTotalHoursForPeriod as calcTotalHoursForPeriod, getRevenueForPeriod as calcRevenueForPeriod } from '@/utils/calculationUtils'; +import { + exportToCSV as utilExportToCSV, + exportToJSON as utilExportToJSON, + generateInvoiceData as utilGenerateInvoiceData, + parseCSVImport +} from '@/utils/exportUtils'; export interface Task { id: string; @@ -921,453 +926,45 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ const getNonBillableHoursForDay = (day: DayRecord): number => calcNonBillableHoursForDay(day, projects, categories); - const exportToCSV = (startDate?: Date, endDate?: Date): string => { - let filteredDays = archivedDays; - - if (startDate && endDate) { - filteredDays = archivedDays.filter(day => { - const dayDate = new Date(day.startTime); - return dayDate >= startDate && dayDate <= endDate; - }); - } - - // CSV headers matching database schema exactly - const headers = [ - 'id', - 'user_id', - 'title', - 'description', - 'start_time', - 'end_time', - 'duration', - 'project_id', - 'project_name', - 'client', - 'category_id', - 'category_name', - 'day_record_id', - 'is_current', - 'inserted_at', - 'updated_at', - 'daily_summary' - ]; - const rows = [headers.join(',')]; - - filteredDays.forEach(day => { - // Generate daily summary once per day - const dayDescriptions = day.tasks - .filter(t => t.description) - .map(t => t.description!); - const dailySummary = generateDailySummary(dayDescriptions); - - day.tasks.forEach(task => { - if (task.duration) { - const project = projects.find(p => p.name === task.project); - // Fix: Look up category by ID, not name - const category = categories.find(c => c.id === task.category); - - // Format timestamps as ISO strings for database compatibility - const startTimeISO = task.startTime.toISOString(); - const endTimeISO = task.endTime?.toISOString() || ''; - // Use actual timestamps from database, or current time as fallback - const insertedAtISO = - task.insertedAt?.toISOString() || new Date().toISOString(); - const updatedAtISO = - task.updatedAt?.toISOString() || new Date().toISOString(); - - const row = [ - `"${task.id}"`, - `"${user?.id || ''}"`, // user_id from auth context - `"${task.title}"`, - `"${task.description || ''}"`, - `"${startTimeISO}"`, - `"${endTimeISO}"`, - task.duration || '', // duration in milliseconds - `"${project?.id || ''}"`, // project_id - `"${task.project || ''}"`, // project_name (denormalized) - `"${task.client || ''}"`, - `"${category?.id || ''}"`, // category_id - `"${task.category || ''}"`, // category_name (denormalized) - `"${day.id}"`, // day_record_id - 'false', // is_current - archived tasks are not current - `"${insertedAtISO}"`, // inserted_at - actual database timestamp - `"${updatedAtISO}"`, // updated_at - actual database timestamp - `"${dailySummary.replace(/"/g, '""')}"` // daily_summary - escape quotes for CSV - ]; - rows.push(row.join(',')); - } - }); - }); - - return rows.join('\n'); - }; + const exportToCSV = (startDate?: Date, endDate?: Date): string => + utilExportToCSV(archivedDays, projects, categories, user?.id || '', startDate, endDate); - const exportToJSON = (startDate?: Date, endDate?: Date): string => { - let filteredDays = archivedDays; - - if (startDate && endDate) { - filteredDays = archivedDays.filter(day => { - const dayDate = new Date(day.startTime); - return dayDate >= startDate && dayDate <= endDate; - }); - } - - // Add daily summary to each day - const daysWithSummary = filteredDays.map(day => { - const dayDescriptions = day.tasks - .filter(t => t.description) - .map(t => t.description!); - const dailySummary = generateDailySummary(dayDescriptions); - - return { - ...day, - dailySummary - }; - }); - - const exportData = { - exportDate: new Date().toISOString(), - period: { - startDate: startDate?.toISOString(), - endDate: endDate?.toISOString() - }, - summary: { - totalDays: filteredDays.length, - totalHours: getTotalHoursForPeriod( - startDate || new Date(0), - endDate || new Date() - ), - totalRevenue: getRevenueForPeriod( - startDate || new Date(0), - endDate || new Date() - ) - }, - days: daysWithSummary, - projects: projects - }; - - return JSON.stringify(exportData, null, 2); - }; + const exportToJSON = (startDate?: Date, endDate?: Date): string => + utilExportToJSON(archivedDays, projects, categories, startDate, endDate); const generateInvoiceData = ( clientName: string, startDate: Date, endDate: Date - ) => { - // Create lookup maps for O(1) access (performance optimization) - const projectMap = new Map(projects.map(p => [p.name, p])); - const categoryMap = new Map(categories.map(c => [c.id, c])); - - const filteredDays = archivedDays.filter(day => { - const dayDate = new Date(day.startTime); - return dayDate >= startDate && dayDate <= endDate; - }); - - // Generate daily summaries for all days in the period - const dailySummaries: { - [dayId: string]: { date: string; summary: string }; - } = {}; - filteredDays.forEach(day => { - const dayDescriptions = day.tasks - .filter(t => t.description) - .map(t => t.description!); - const summary = generateDailySummary(dayDescriptions); - - if (summary) { - dailySummaries[day.id] = { - date: day.date, - summary - }; - } - }); - - const clientTasks = filteredDays.flatMap(day => - day.tasks - .filter(task => { - if (!task.client || task.client !== clientName || !task.duration) { - return false; - } - - // Only include billable tasks in invoices - if (task.project && task.category) { - const project = projectMap.get(task.project); - const category = categoryMap.get(task.category); - - const projectIsBillable = project?.isBillable !== false; - const categoryIsBillable = category?.isBillable !== false; - - // Task must be billable to appear on invoice - return projectIsBillable && categoryIsBillable; - } - - return false; - }) - .map(task => ({ - ...task, - dayId: day.id, - dayDate: day.date, - dailySummary: dailySummaries[day.id]?.summary || '' - })) - ); - - const projectSummary: { - [key: string]: { hours: number; rate: number; amount: number }; - } = {}; - - clientTasks.forEach(task => { - const projectName = task.project || 'General'; - const project = projectMap.get(task.project); - const hours = (task.duration || 0) / (1000 * 60 * 60); - const rate = project?.hourlyRate || 0; - - if (!projectSummary[projectName]) { - projectSummary[projectName] = { hours: 0, rate, amount: 0 }; - } - - projectSummary[projectName].hours += hours; - projectSummary[projectName].amount += hours * rate; - }); - - const totalHours = Object.values(projectSummary).reduce( - (sum, proj) => sum + proj.hours, - 0 - ); - const totalAmount = Object.values(projectSummary).reduce( - (sum, proj) => sum + proj.amount, - 0 - ); - - return { - client: clientName, - period: { startDate, endDate }, - projects: projectSummary, - summary: { - totalHours: Math.round(totalHours * 100) / 100, - totalAmount: Math.round(totalAmount * 100) / 100 - }, - tasks: clientTasks, - dailySummaries - }; - }; + ): InvoiceData => + utilGenerateInvoiceData(archivedDays, projects, categories, clientName, startDate, endDate); const importFromCSV = async ( csvContent: string ): Promise<{ success: boolean; message: string; importedCount: number }> => { try { - const lines = csvContent.split('\n').filter(line => line.trim()); - if (lines.length === 0) { - return { - success: false, - message: 'CSV file is empty', - importedCount: 0 - }; - } - - const headerLine = lines[0]; - const expectedHeaders = [ - 'id', - 'user_id', - 'title', - 'description', - 'start_time', - 'end_time', - 'duration', - 'project_id', - 'project_name', - 'client', - 'category_id', - 'category_name', - 'day_record_id', - 'is_current', - 'inserted_at', - 'updated_at' - ]; - - // Validate headers - const headers = headerLine - .split(',') - .map(h => h.trim().replace(/"/g, '')); - const missingHeaders = expectedHeaders.filter(h => !headers.includes(h)); - if (missingHeaders.length > 0) { - return { - success: false, - message: `CSV missing required headers: ${missingHeaders.join(', ')}`, - importedCount: 0 - }; - } - - const tasksByDay: { - [dayId: string]: { tasks: Task[]; dayRecord: Partial }; - } = {}; - let importedCount = 0; - - // Process each data line - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - try { - // Parse CSV line (handle quoted values) - const values: string[] = []; - let current = ''; - let inQuotes = false; - - for (let j = 0; j < line.length; j++) { - const char = line[j]; - if (char === '"') { - inQuotes = !inQuotes; - } else if (char === ',' && !inQuotes) { - values.push(current.trim()); - current = ''; - } else { - current += char; - } - } - values.push(current.trim()); // Add last value - - if (values.length !== headers.length) { - console.warn( - `Skipping malformed CSV line ${i + 1}: expected ${headers.length} columns, got ${values.length}` - ); - continue; - } - - // Create task object from CSV data - const taskData: { [key: string]: string } = {}; - headers.forEach((header, index) => { - taskData[header] = values[index].replace(/^"|"$/g, ''); // Remove quotes - }); - - // Validate required fields - if (!taskData.id || !taskData.title || !taskData.start_time) { - console.warn( - `Skipping incomplete task on line ${i + 1}: missing required fields` - ); - continue; - } - - // Map category name back to category ID for proper storage - const categoryByName = categories.find( - c => c.name === taskData.category_name - ); - const categoryId = - categoryByName?.id || taskData.category_id || undefined; - - const task: Task = { - id: taskData.id, - title: taskData.title, - description: taskData.description || undefined, - startTime: new Date(taskData.start_time), - endTime: taskData.end_time - ? new Date(taskData.end_time) - : undefined, - duration: taskData.duration - ? parseInt(taskData.duration) - : undefined, - project: taskData.project_name || undefined, - client: taskData.client || undefined, - category: categoryId // Use category ID, not name - }; - - // Validate dates - if (isNaN(task.startTime.getTime())) { - console.warn( - `Skipping task with invalid start_time on line ${i + 1}` - ); - continue; - } - - if (task.endTime && isNaN(task.endTime.getTime())) { - task.endTime = undefined; - } - - const dayRecordId = taskData.day_record_id; - if (!dayRecordId) { - console.warn( - `Skipping task without day_record_id on line ${i + 1}` - ); - continue; - } - - // Group tasks by day - if (!tasksByDay[dayRecordId]) { - tasksByDay[dayRecordId] = { - tasks: [], - dayRecord: { - id: dayRecordId, - date: task.startTime.toISOString().split('T')[0], - startTime: task.startTime, - endTime: task.endTime || task.startTime, - totalDuration: 0, - tasks: [] - } - }; - } - - tasksByDay[dayRecordId].tasks.push(task); - - // Update day record bounds - if ( - task.startTime < - (tasksByDay[dayRecordId].dayRecord.startTime || new Date()) - ) { - tasksByDay[dayRecordId].dayRecord.startTime = task.startTime; - } - if ( - task.endTime && - task.endTime > - (tasksByDay[dayRecordId].dayRecord.endTime || new Date(0)) - ) { - tasksByDay[dayRecordId].dayRecord.endTime = task.endTime; - } - - importedCount++; - } catch (error) { - console.warn(`Error parsing line ${i + 1}:`, error); - continue; - } - } - - // Create day records and add to archived days - const newArchivedDays: DayRecord[] = []; - - for (const [dayId, { tasks, dayRecord }] of Object.entries(tasksByDay)) { - const totalDuration = tasks.reduce( - (sum, task) => sum + (task.duration || 0), - 0 - ); - - const completeDay: DayRecord = { - id: dayRecord.id!, - date: dayRecord.date!, - tasks: tasks, - totalDuration: totalDuration, - startTime: dayRecord.startTime!, - endTime: dayRecord.endTime!, - notes: dayRecord.notes - }; - - newArchivedDays.push(completeDay); + const result = parseCSVImport(csvContent, categories); + if (!result.success) { + return { success: false, message: result.message, importedCount: 0 }; } // Merge with existing archived days (avoid duplicates) const existingIds = new Set(archivedDays.map(day => day.id)); - const uniqueNewDays = newArchivedDays.filter( + const uniqueNewDays = result.newArchivedDays.filter( day => !existingIds.has(day.id) ); const updatedArchivedDays = [...archivedDays, ...uniqueNewDays]; setArchivedDays(updatedArchivedDays); - // Save to storage if (dataService) { await dataService.saveArchivedDays(updatedArchivedDays); } return { success: true, - message: `Successfully imported ${importedCount} tasks in ${uniqueNewDays.length} days`, - importedCount + message: `Successfully imported ${result.importedCount} tasks in ${uniqueNewDays.length} days`, + importedCount: result.importedCount }; } catch (error) { console.error('CSV import error:', error); diff --git a/src/utils/exportUtils.ts b/src/utils/exportUtils.ts new file mode 100644 index 0000000..d439854 --- /dev/null +++ b/src/utils/exportUtils.ts @@ -0,0 +1,394 @@ +import { Task, DayRecord, Project, InvoiceData } from "@/contexts/TimeTrackingContext"; +import { TaskCategory } from "@/config/categories"; +import { generateDailySummary } from "@/utils/timeUtil"; +import { getTotalHoursForPeriod, getRevenueForPeriod } from "@/utils/calculationUtils"; + +export function exportToCSV( + archivedDays: DayRecord[], + projects: Project[], + categories: TaskCategory[], + userId: string, + startDate?: Date, + endDate?: Date +): string { + let filteredDays = archivedDays; + + if (startDate && endDate) { + filteredDays = archivedDays.filter(day => { + const dayDate = new Date(day.startTime); + return dayDate >= startDate && dayDate <= endDate; + }); + } + + const headers = [ + "id", + "user_id", + "title", + "description", + "start_time", + "end_time", + "duration", + "project_id", + "project_name", + "client", + "category_id", + "category_name", + "day_record_id", + "is_current", + "inserted_at", + "updated_at", + "daily_summary" + ]; + const rows = [headers.join(",")]; + + filteredDays.forEach(day => { + const dayDescriptions = day.tasks.filter(t => t.description).map(t => t.description!); + const dailySummary = generateDailySummary(dayDescriptions); + + day.tasks.forEach(task => { + if (task.duration) { + const project = projects.find(p => p.name === task.project); + const category = categories.find(c => c.id === task.category); + + const startTimeISO = task.startTime.toISOString(); + const endTimeISO = task.endTime?.toISOString() || ""; + const insertedAtISO = task.insertedAt?.toISOString() || new Date().toISOString(); + const updatedAtISO = task.updatedAt?.toISOString() || new Date().toISOString(); + + const row = [ + `"${task.id}"`, + `"${userId}"`, + `"${task.title}"`, + `"${task.description || ""}"`, + `"${startTimeISO}"`, + `"${endTimeISO}"`, + task.duration || "", + `"${project?.id || ""}"`, + `"${task.project || ""}"`, + `"${task.client || ""}"`, + `"${category?.id || ""}"`, + `"${task.category || ""}"`, + `"${day.id}"`, + "false", + `"${insertedAtISO}"`, + `"${updatedAtISO}"`, + `"${dailySummary.replace(/"/g, '""')}"` + ]; + rows.push(row.join(",")); + } + }); + }); + + return rows.join("\n"); +} + +export function exportToJSON( + archivedDays: DayRecord[], + projects: Project[], + categories: TaskCategory[], + startDate?: Date, + endDate?: Date +): string { + let filteredDays = archivedDays; + + if (startDate && endDate) { + filteredDays = archivedDays.filter(day => { + const dayDate = new Date(day.startTime); + return dayDate >= startDate && dayDate <= endDate; + }); + } + + const daysWithSummary = filteredDays.map(day => { + const dayDescriptions = day.tasks.filter(t => t.description).map(t => t.description!); + const dailySummary = generateDailySummary(dayDescriptions); + return { ...day, dailySummary }; + }); + + const exportData = { + exportDate: new Date().toISOString(), + period: { + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString() + }, + summary: { + totalDays: filteredDays.length, + totalHours: getTotalHoursForPeriod( + archivedDays, + startDate || new Date(0), + endDate || new Date() + ), + totalRevenue: getRevenueForPeriod( + archivedDays, + projects, + categories, + startDate || new Date(0), + endDate || new Date() + ) + }, + days: daysWithSummary, + projects + }; + + return JSON.stringify(exportData, null, 2); +} + +export function generateInvoiceData( + archivedDays: DayRecord[], + projects: Project[], + categories: TaskCategory[], + clientName: string, + startDate: Date, + endDate: Date +): InvoiceData { + const projectMap = new Map(projects.map(p => [p.name, p])); + const categoryMap = new Map(categories.map(c => [c.id, c])); + + const filteredDays = archivedDays.filter(day => { + const dayDate = new Date(day.startTime); + return dayDate >= startDate && dayDate <= endDate; + }); + + const dailySummaries: { [dayId: string]: { date: string; summary: string } } = {}; + filteredDays.forEach(day => { + const dayDescriptions = day.tasks.filter(t => t.description).map(t => t.description!); + const summary = generateDailySummary(dayDescriptions); + if (summary) { + dailySummaries[day.id] = { date: day.date, summary }; + } + }); + + const clientTasks = filteredDays.flatMap(day => + day.tasks + .filter(task => { + if (!task.client || task.client !== clientName || !task.duration) return false; + + if (task.project && task.category) { + const project = projectMap.get(task.project); + const category = categoryMap.get(task.category); + const projectIsBillable = project?.isBillable !== false; + const categoryIsBillable = category?.isBillable !== false; + return projectIsBillable && categoryIsBillable; + } + + return false; + }) + .map(task => ({ + ...task, + dayId: day.id, + dayDate: day.date, + dailySummary: dailySummaries[day.id]?.summary || "" + })) + ); + + const projectSummary: { + [key: string]: { hours: number; rate: number; amount: number }; + } = {}; + + clientTasks.forEach(task => { + const projectName = task.project || "General"; + const project = projectMap.get(task.project); + const hours = (task.duration || 0) / (1000 * 60 * 60); + const rate = project?.hourlyRate || 0; + + if (!projectSummary[projectName]) { + projectSummary[projectName] = { hours: 0, rate, amount: 0 }; + } + + projectSummary[projectName].hours += hours; + projectSummary[projectName].amount += hours * rate; + }); + + const totalHours = Object.values(projectSummary).reduce((sum, proj) => sum + proj.hours, 0); + const totalAmount = Object.values(projectSummary).reduce((sum, proj) => sum + proj.amount, 0); + + return { + client: clientName, + period: { startDate, endDate }, + projects: projectSummary, + summary: { + totalHours: Math.round(totalHours * 100) / 100, + totalAmount: Math.round(totalAmount * 100) / 100 + }, + tasks: clientTasks, + dailySummaries + }; +} + +const EXPECTED_IMPORT_HEADERS = [ + "id", + "user_id", + "title", + "description", + "start_time", + "end_time", + "duration", + "project_id", + "project_name", + "client", + "category_id", + "category_name", + "day_record_id", + "is_current", + "inserted_at", + "updated_at" +]; + +export interface ParsedImportResult { + success: boolean; + message: string; + importedCount: number; + newArchivedDays: DayRecord[]; +} + +export function parseCSVImport( + csvContent: string, + categories: TaskCategory[] +): ParsedImportResult { + const lines = csvContent.split("\n").filter(line => line.trim()); + if (lines.length === 0) { + return { success: false, message: "CSV file is empty", importedCount: 0, newArchivedDays: [] }; + } + + const headerLine = lines[0]; + const headers = headerLine.split(",").map(h => h.trim().replace(/"/g, "")); + const missingHeaders = EXPECTED_IMPORT_HEADERS.filter(h => !headers.includes(h)); + if (missingHeaders.length > 0) { + return { + success: false, + message: `CSV missing required headers: ${missingHeaders.join(", ")}`, + importedCount: 0, + newArchivedDays: [] + }; + } + + const tasksByDay: { + [dayId: string]: { tasks: Task[]; dayRecord: Partial }; + } = {}; + let importedCount = 0; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + try { + const values: string[] = []; + let current = ""; + let inQuotes = false; + + for (let j = 0; j < line.length; j++) { + const char = line[j]; + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === "," && !inQuotes) { + values.push(current.trim()); + current = ""; + } else { + current += char; + } + } + values.push(current.trim()); + + if (values.length !== headers.length) { + console.warn( + `Skipping malformed CSV line ${i + 1}: expected ${headers.length} columns, got ${values.length}` + ); + continue; + } + + const taskData: { [key: string]: string } = {}; + headers.forEach((header, index) => { + taskData[header] = values[index].replace(/^"|"$/g, ""); + }); + + if (!taskData.id || !taskData.title || !taskData.start_time) { + console.warn(`Skipping incomplete task on line ${i + 1}: missing required fields`); + continue; + } + + const categoryByName = categories.find(c => c.name === taskData.category_name); + const categoryId = categoryByName?.id || taskData.category_id || undefined; + + const task: Task = { + id: taskData.id, + title: taskData.title, + description: taskData.description || undefined, + startTime: new Date(taskData.start_time), + endTime: taskData.end_time ? new Date(taskData.end_time) : undefined, + duration: taskData.duration ? parseInt(taskData.duration) : undefined, + project: taskData.project_name || undefined, + client: taskData.client || undefined, + category: categoryId + }; + + if (isNaN(task.startTime.getTime())) { + console.warn(`Skipping task with invalid start_time on line ${i + 1}`); + continue; + } + + if (task.endTime && isNaN(task.endTime.getTime())) { + task.endTime = undefined; + } + + const dayRecordId = taskData.day_record_id; + if (!dayRecordId) { + console.warn(`Skipping task without day_record_id on line ${i + 1}`); + continue; + } + + if (!tasksByDay[dayRecordId]) { + tasksByDay[dayRecordId] = { + tasks: [], + dayRecord: { + id: dayRecordId, + date: task.startTime.toISOString().split("T")[0], + startTime: task.startTime, + endTime: task.endTime || task.startTime, + totalDuration: 0, + tasks: [] + } + }; + } + + tasksByDay[dayRecordId].tasks.push(task); + + if (task.startTime < (tasksByDay[dayRecordId].dayRecord.startTime || new Date())) { + tasksByDay[dayRecordId].dayRecord.startTime = task.startTime; + } + if ( + task.endTime && + task.endTime > (tasksByDay[dayRecordId].dayRecord.endTime || new Date(0)) + ) { + tasksByDay[dayRecordId].dayRecord.endTime = task.endTime; + } + + importedCount++; + } catch (error) { + console.warn(`Error parsing line ${i + 1}:`, error); + } + } + + const newArchivedDays: DayRecord[] = []; + + for (const [, { tasks, dayRecord }] of Object.entries(tasksByDay)) { + const totalDuration = tasks.reduce((sum, task) => sum + (task.duration || 0), 0); + + const completeDay: DayRecord = { + id: dayRecord.id!, + date: dayRecord.date!, + tasks, + totalDuration, + startTime: dayRecord.startTime!, + endTime: dayRecord.endTime!, + notes: dayRecord.notes + }; + + newArchivedDays.push(completeDay); + } + + return { + success: true, + message: `Successfully imported ${importedCount} tasks in ${newArchivedDays.length} days`, + importedCount, + newArchivedDays + }; +} From e1c9161ed0b44096463af66a2efd937845be2425 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 19:51:37 +0000 Subject: [PATCH 04/14] refactor: extract TaskEditInArchiveDialog into its own component file Move the task-editing dialog from ArchiveEditDialog.tsx into a dedicated TaskEditInArchiveDialog.tsx, reducing ArchiveEditDialog from 850 to ~270 lines. Unused Select imports are removed from ArchiveEditDialog. https://claude.ai/code/session_012AmNjF3Ju9VJwSWybJqBEw --- src/components/ArchiveEditDialog.tsx | 259 +------------------ src/components/TaskEditInArchiveDialog.tsx | 286 +++++++++++++++++++++ 2 files changed, 287 insertions(+), 258 deletions(-) create mode 100644 src/components/TaskEditInArchiveDialog.tsx diff --git a/src/components/ArchiveEditDialog.tsx b/src/components/ArchiveEditDialog.tsx index 2108b6e..54126ed 100644 --- a/src/components/ArchiveEditDialog.tsx +++ b/src/components/ArchiveEditDialog.tsx @@ -13,13 +13,6 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { MarkdownDisplay } from '@/components/MarkdownDisplay'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select'; import { TimePicker } from '@/components/ui/scroll-time-picker'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -42,6 +35,7 @@ import { import { formatDuration, formatDate } from '@/utils/timeUtil'; import { DayRecord, Task } from '@/contexts/TimeTrackingContext'; import { useTimeTracking } from '@/hooks/useTimeTracking'; +import { TaskEditInArchiveDialog } from '@/components/TaskEditInArchiveDialog'; interface ArchiveEditDialogProps { day: DayRecord; @@ -596,254 +590,3 @@ export const ArchiveEditDialog: React.FC = ({ ); }; -// Separate component for editing tasks within archived days -interface TaskEditInArchiveDialogProps { - task: Task; - isOpen: boolean; - onClose: () => void; - onSave: (task: Task) => void; -} - -const TaskEditInArchiveDialog: React.FC = ({ - task, - isOpen, - onClose, - onSave -}) => { - const { projects, categories } = useTimeTracking(); - const [formData, setFormData] = useState({ - title: '', - description: '', - project: 'none', - category: 'none' - }); - - const [timeData, setTimeData] = useState({ - startTime: '', - endTime: '' - }); - - useEffect(() => { - if (isOpen && task) { - const projectId = - projects.find(p => p.name === task.project)?.id || 'none'; - - setFormData({ - title: task.title || '', - description: task.description || '', - project: projectId, - category: task.category || 'none' - }); - - setTimeData({ - startTime: formatTimeForInput(task.startTime), - endTime: task.endTime ? formatTimeForInput(task.endTime) : '' - }); - } - }, [task, projects, isOpen]); - - const parseTimeInput = (timeStr: string, baseDate: Date): Date => { - if (!timeStr || !timeStr.includes(':')) { - return baseDate; - } - - const [hoursStr, minutesStr] = timeStr.split(':'); - const hours = parseInt(hoursStr, 10); - const minutes = parseInt(minutesStr, 10); - - if (isNaN(hours) || isNaN(minutes)) { - return baseDate; - } - - const newDate = new Date(baseDate); - newDate.setHours(hours, minutes, 0, 0); - return newDate; - }; - - const handleSave = () => { - const selectedProject = - formData.project !== 'none' - ? projects.find(p => p.id === formData.project) - : undefined; - const selectedCategory = - formData.category !== 'none' - ? categories.find(c => c.id === formData.category) - : undefined; - - const newStartTime = parseTimeInput(timeData.startTime, task.startTime); - const newEndTime = timeData.endTime - ? parseTimeInput(timeData.endTime, task.startTime) - : undefined; - - const updatedTask: Task = { - ...task, - title: formData.title.trim(), - description: formData.description.trim() || undefined, - project: selectedProject?.name || undefined, - client: selectedProject?.client || undefined, - category: selectedCategory?.id || undefined, - startTime: newStartTime, - endTime: newEndTime, - duration: newEndTime - ? newEndTime.getTime() - newStartTime.getTime() - : task.duration - }; - - onSave(updatedTask); - }; - - return ( - - - - Edit Task - - -
-
- - - setFormData(prev => ({ ...prev, title: e.target.value })) - } - placeholder="Enter task title" - /> -
- -
- - - - Edit - Preview - - -