diff options
Diffstat (limited to 'internal/reports')
-rw-r--r-- | internal/reports/pdf.go | 203 | ||||
-rw-r--r-- | internal/reports/pdf_test.go | 4 | ||||
-rw-r--r-- | internal/reports/testdata/invoice_test_data.json | 6 | ||||
-rw-r--r-- | internal/reports/timesheet.go | 185 | ||||
-rw-r--r-- | internal/reports/timesheet_test.go | 541 | ||||
-rw-r--r-- | internal/reports/unified.go | 83 | ||||
-rw-r--r-- | internal/reports/unified_test.go | 562 |
7 files changed, 1578 insertions, 6 deletions
diff --git a/internal/reports/pdf.go b/internal/reports/pdf.go index 96630cf..8434f07 100644 --- a/internal/reports/pdf.go +++ b/internal/reports/pdf.go @@ -55,7 +55,7 @@ func GenerateInvoicePDF(invoiceData *InvoiceData, outputPath string) error { if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } - defer os.RemoveAll(tempDir) + defer func() { _ = os.RemoveAll(tempDir) }() // Create JSON data for Typst template jsonData := InvoiceJSONData{ @@ -129,3 +129,204 @@ func GenerateDefaultInvoiceFilename(clientName, projectName string, dateRange Da return fmt.Sprintf("invoice_%s_%s_%s.pdf", name, dateStr, timestamp) } + +// TimesheetJSONData represents the data structure for the JSON file that Typst will consume +type TimesheetJSONData struct { + ClientName string `json:"client_name"` + ProjectName string `json:"project_name"` + DateRangeStart string `json:"date_range_start"` + DateRangeEnd string `json:"date_range_end"` + GeneratedDate string `json:"generated_date"` + ContractorName string `json:"contractor_name"` + ContractorLabel string `json:"contractor_label"` + ContractorEmail string `json:"contractor_email"` + Entries []TimesheetEntry `json:"entries"` + TotalHours float64 `json:"total_hours"` + Timezone string `json:"timezone"` +} + +func GenerateTimesheetPDF(timesheetData *TimesheetData, outputPath string) error { + // Check if Typst is installed + if err := checkTypstInstalled(); err != nil { + return err + } + + // Create temporary directory for template and data files + tempDir, err := os.MkdirTemp("", "punchcard-timesheet") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + // Create JSON data for Typst template + jsonData := TimesheetJSONData{ + ClientName: timesheetData.ClientName, + ProjectName: timesheetData.ProjectName, + DateRangeStart: timesheetData.DateRange.Start.Format("2006-01-02"), + DateRangeEnd: timesheetData.DateRange.End.Format("2006-01-02"), + GeneratedDate: timesheetData.GeneratedDate.Format("2006-01-02"), + ContractorName: timesheetData.ContractorName, + ContractorLabel: timesheetData.ContractorLabel, + ContractorEmail: timesheetData.ContractorEmail, + Entries: timesheetData.Entries, + TotalHours: timesheetData.TotalHours, + Timezone: timesheetData.Timezone, + } + + // Write JSON data file + dataFile := filepath.Join(tempDir, "data.json") + jsonBytes, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON data: %w", err) + } + + if err := os.WriteFile(dataFile, jsonBytes, 0o644); err != nil { + return fmt.Errorf("failed to write JSON data file: %w", err) + } + + // Write Typst template file + typstFile := filepath.Join(tempDir, "timesheet.typ") + if err := os.WriteFile(typstFile, []byte(templates.TimesheetTemplate), 0o644); err != nil { + return fmt.Errorf("failed to write Typst template file: %w", err) + } + + // Generate PDF using Typst + cmd := exec.Command("typst", "compile", typstFile, outputPath) + cmd.Dir = tempDir // Set working directory so Typst can find data.json + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to generate PDF: %w\nTypst output: %s", err, string(output)) + } + + return nil +} + +func GenerateDefaultTimesheetFilename(clientName, projectName string, dateRange DateRange) string { + var name string + if projectName != "" { + name = fmt.Sprintf("%s_%s", clientName, projectName) + } else { + name = clientName + } + + // Replace spaces and special characters + name = filepath.Base(name) + + dateStr := dateRange.Start.Format("2006-01") + timestamp := time.Now().Format("20060102_150405") + + return fmt.Sprintf("timesheet_%s_%s_%s.pdf", name, dateStr, timestamp) +} + +// UnifiedJSONData represents the unified data structure containing all fields from both invoice and timesheet +type UnifiedJSONData struct { + // Common fields + ClientName string `json:"client_name"` + ProjectName string `json:"project_name"` + DateRangeStart string `json:"date_range_start"` + DateRangeEnd string `json:"date_range_end"` + GeneratedDate string `json:"generated_date"` + ContractorName string `json:"contractor_name"` + ContractorLabel string `json:"contractor_label"` + ContractorEmail string `json:"contractor_email"` + + // Invoice-specific fields + InvoiceNumber string `json:"invoice_number"` + LineItems []LineItem `json:"line_items"` + TotalAmount float64 `json:"total_amount"` + + // Timesheet-specific fields + Entries []TimesheetEntry `json:"entries"` + Timezone string `json:"timezone"` + + // Shared field with same value + TotalHours float64 `json:"total_hours"` +} + +func GenerateUnifiedPDF(unifiedData *UnifiedReportData, outputPath string) error { + // Check if Typst is installed + if err := checkTypstInstalled(); err != nil { + return err + } + + // Create temporary directory for template and data files + tempDir, err := os.MkdirTemp("", "punchcard-unified") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + // Create unified JSON data containing all fields needed by both templates + jsonData := UnifiedJSONData{ + // Common fields (from invoice data, but both should be identical) + ClientName: unifiedData.InvoiceData.ClientName, + ProjectName: unifiedData.InvoiceData.ProjectName, + DateRangeStart: unifiedData.InvoiceData.DateRange.Start.Format("2006-01-02"), + DateRangeEnd: unifiedData.InvoiceData.DateRange.End.Format("2006-01-02"), + GeneratedDate: unifiedData.InvoiceData.GeneratedDate.Format("2006-01-02"), + ContractorName: unifiedData.InvoiceData.ContractorName, + ContractorLabel: unifiedData.InvoiceData.ContractorLabel, + ContractorEmail: unifiedData.InvoiceData.ContractorEmail, + TotalHours: unifiedData.InvoiceData.TotalHours, // Should match timesheet total + + // Invoice-specific fields + InvoiceNumber: fmt.Sprintf("%04d-%02d-%03d", + unifiedData.InvoiceData.DateRange.Start.Year(), + unifiedData.InvoiceData.DateRange.Start.Month(), + unifiedData.InvoiceData.InvoiceNumber, + ), + LineItems: unifiedData.InvoiceData.LineItems, + TotalAmount: unifiedData.InvoiceData.TotalAmount, + + // Timesheet-specific fields + Entries: unifiedData.TimesheetData.Entries, + Timezone: unifiedData.TimesheetData.Timezone, + } + + // Write JSON data file + dataFile := filepath.Join(tempDir, "data.json") + jsonBytes, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON data: %w", err) + } + + if err := os.WriteFile(dataFile, jsonBytes, 0o644); err != nil { + return fmt.Errorf("failed to write JSON data file: %w", err) + } + + // Create unified template by combining invoice and timesheet templates + unifiedTemplate := templates.InvoiceTemplate + "\n\n#pagebreak()\n\n" + templates.TimesheetTemplate + + // Write Typst template file + typstFile := filepath.Join(tempDir, "unified.typ") + if err := os.WriteFile(typstFile, []byte(unifiedTemplate), 0o644); err != nil { + return fmt.Errorf("failed to write Typst template file: %w", err) + } + + // Generate PDF using Typst + cmd := exec.Command("typst", "compile", typstFile, outputPath) + cmd.Dir = tempDir // Set working directory so Typst can find data.json + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to generate PDF: %w\nTypst output: %s", err, string(output)) + } + + return nil +} + +func GenerateDefaultUnifiedFilename(clientName, projectName string, dateRange DateRange) string { + var name string + if projectName != "" { + name = fmt.Sprintf("%s_%s", clientName, projectName) + } else { + name = clientName + } + + // Replace spaces and special characters + name = filepath.Base(name) + + dateStr := dateRange.Start.Format("2006-01") + timestamp := time.Now().Format("20060102_150405") + + return fmt.Sprintf("unified_%s_%s_%s.pdf", name, dateStr, timestamp) +} diff --git a/internal/reports/pdf_test.go b/internal/reports/pdf_test.go index e1c4020..b31112e 100644 --- a/internal/reports/pdf_test.go +++ b/internal/reports/pdf_test.go @@ -29,7 +29,7 @@ func TestTypstTemplateCompilation(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } - defer os.RemoveAll(tempDir) + defer func() { _ = os.RemoveAll(tempDir) }() // Copy test data to temp directory testDataPath := filepath.Join("testdata", "invoice_test_data.json") @@ -104,7 +104,7 @@ func TestGenerateInvoicePDF(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp directory: %v", err) } - defer os.RemoveAll(tempDir) + defer func() { _ = os.RemoveAll(tempDir) }() outputPath := filepath.Join(tempDir, "test-invoice.pdf") diff --git a/internal/reports/testdata/invoice_test_data.json b/internal/reports/testdata/invoice_test_data.json index 19ae7cb..a6adc28 100644 --- a/internal/reports/testdata/invoice_test_data.json +++ b/internal/reports/testdata/invoice_test_data.json @@ -21,7 +21,7 @@ ], "total_hours": 10.75, "total_amount": 1612.5, - "consultant_name": "Travis Parker", - "consultant_label": "Software Development", - "consultant_email": "travis.parker@gmail.com", + "contractor_name": "Travis Parker", + "contractor_label": "Software Development", + "contractor_email": "travis.parker@gmail.com" } diff --git a/internal/reports/timesheet.go b/internal/reports/timesheet.go new file mode 100644 index 0000000..a40d8ae --- /dev/null +++ b/internal/reports/timesheet.go @@ -0,0 +1,185 @@ +package reports + +import ( + "database/sql" + "fmt" + "time" + + "punchcard/internal/queries" +) + +type TimesheetData struct { + ClientID int64 + ClientName string + ProjectName string + ContractorName string + ContractorLabel string + ContractorEmail string + DateRange DateRange + Entries []TimesheetEntry + TotalHours float64 + GeneratedDate time.Time + Timezone string +} + +type TimesheetEntry struct { + Date string `json:"date"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Duration string `json:"duration"` + Hours float64 `json:"hours"` + ProjectName string `json:"project_name"` + Description string `json:"description"` +} + +type timesheetEntryData struct { + TimeEntryID int64 + StartTime time.Time + EndTime sql.NullTime + Description sql.NullString + ClientID int64 + ClientName string + ProjectID sql.NullInt64 + ProjectName sql.NullString + DurationSeconds int64 +} + +func GenerateTimesheetData( + entries interface{}, + clientID int64, + clientName, + projectName string, + contractor queries.Contractor, + dateRange DateRange, + loc *time.Location, +) (*TimesheetData, error) { + var timeEntries []timesheetEntryData + + switch e := entries.(type) { + case []queries.GetTimesheetDataByClientRow: + for _, entry := range e { + timeEntries = append(timeEntries, timesheetEntryData{ + TimeEntryID: entry.TimeEntryID, + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + ClientID: entry.ClientID, + ClientName: entry.ClientName, + ProjectID: entry.ProjectID, + ProjectName: entry.ProjectName, + DurationSeconds: entry.DurationSeconds, + }) + } + case []queries.GetTimesheetDataByProjectRow: + for _, entry := range e { + timeEntries = append(timeEntries, timesheetEntryData{ + TimeEntryID: entry.TimeEntryID, + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + ClientID: entry.ClientID, + ClientName: entry.ClientName, + ProjectID: sql.NullInt64{Int64: entry.ProjectID, Valid: true}, + ProjectName: sql.NullString{String: entry.ProjectName, Valid: true}, + DurationSeconds: entry.DurationSeconds, + }) + } + default: + return nil, fmt.Errorf("unsupported entry type") + } + + timesheetEntries := convertToTimesheetEntries(timeEntries, loc) + + // Calculate total hours from raw seconds (same method as invoices) + totalSeconds := int64(0) + for _, entry := range timeEntries { + totalSeconds += entry.DurationSeconds + } + totalHours := float64(totalSeconds) / 3600.0 + + // Get the actual timezone name (not "Local" but the real zone name) + timezoneName := loc.String() + if timezoneName == "Local" { + // Get the actual local timezone name by checking current time + now := time.Now() + zone, _ := now.Zone() + timezoneName = zone + } + + timesheet := &TimesheetData{ + ClientID: clientID, + ClientName: clientName, + ProjectName: projectName, + ContractorName: contractor.Name, + ContractorLabel: contractor.Label, + ContractorEmail: contractor.Email, + DateRange: dateRange, + Entries: timesheetEntries, + TotalHours: totalHours, + GeneratedDate: time.Now().UTC(), + Timezone: timezoneName, + } + + return timesheet, nil +} + +func convertToTimesheetEntries(entries []timesheetEntryData, loc *time.Location) []TimesheetEntry { + var timesheetEntries []TimesheetEntry + + for _, entry := range entries { + if !entry.EndTime.Valid { + continue // Skip entries without end time + } + + // Convert UTC times to specified timezone + localStartTime := entry.StartTime.In(loc) + localEndTime := entry.EndTime.Time.In(loc) + + // Format date as YYYY-MM-DD + date := localStartTime.Format("2006-01-02") + + // Format times as HH:MM + startTime := localStartTime.Format("15:04") + endTime := localEndTime.Format("15:04") + + // Format duration as HH:MM + duration := formatDuration(entry.DurationSeconds) + + // Calculate hours as decimal, rounded to nearest minute + totalMinutes := (entry.DurationSeconds + 30) / 60 // Round to nearest minute + hours := float64(totalMinutes) / 60.0 + + // Get project name + projectName := "" + if entry.ProjectName.Valid { + projectName = entry.ProjectName.String + } + + // Get description + description := "" + if entry.Description.Valid { + description = entry.Description.String + } + + timesheetEntries = append(timesheetEntries, TimesheetEntry{ + Date: date, + StartTime: startTime, + EndTime: endTime, + Duration: duration, + Hours: hours, + ProjectName: projectName, + Description: description, + }) + } + + return timesheetEntries +} + +func formatDuration(seconds int64) string { + // Round to nearest minute + totalMinutes := (seconds + 30) / 60 // Add 30 seconds for rounding + hours := totalMinutes / 60 + minutes := totalMinutes % 60 + return fmt.Sprintf("%d:%02d", hours, minutes) +} + diff --git a/internal/reports/timesheet_test.go b/internal/reports/timesheet_test.go new file mode 100644 index 0000000..8c0ac52 --- /dev/null +++ b/internal/reports/timesheet_test.go @@ -0,0 +1,541 @@ +package reports + +import ( + "database/sql" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "punchcard/internal/queries" + "punchcard/templates" +) + +func TestGenerateTimesheetData(t *testing.T) { + tests := []struct { + name string + entries interface{} + clientID int64 + clientName string + projectName string + contractor queries.Contractor + dateRange DateRange + timezone *time.Location + wantEntries int + wantHours float64 + wantError bool + }{ + { + name: "client entries with UTC timezone", + entries: []queries.GetTimesheetDataByClientRow{ + { + TimeEntryID: 1, + StartTime: mustParseTime("2025-07-10T14:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T18:05:00Z"), Valid: true}, + Description: sql.NullString{String: "GL closing", Valid: true}, + DurationSeconds: 11400, // 3:10 + }, + { + TimeEntryID: 2, + StartTime: mustParseTime("2025-07-10T18:42:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T20:04:00Z"), Valid: true}, + Description: sql.NullString{String: "GL closing", Valid: true}, + DurationSeconds: 4920, // 1:22 + }, + }, + clientID: 1, + clientName: "Test Client", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + dateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + timezone: time.UTC, + wantEntries: 2, + wantHours: 4.5333, // 16320 seconds / 3600 + }, + { + name: "project entries with local timezone", + entries: []queries.GetTimesheetDataByProjectRow{ + { + TimeEntryID: 3, + StartTime: mustParseTime("2025-07-11T13:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-11T18:35:00Z"), Valid: true}, + Description: sql.NullString{String: "Development work", Valid: true}, + ProjectID: 1, + ProjectName: "Test Project", + DurationSeconds: 16800, // 4:40 + }, + }, + clientID: 1, + clientName: "Test Client", + projectName: "Test Project", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + dateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + timezone: time.Local, + wantEntries: 1, + wantHours: 4.6667, // 16800 seconds / 3600 + }, + { + name: "entries with different timezone", + entries: []queries.GetTimesheetDataByClientRow{ + { + TimeEntryID: 4, + StartTime: mustParseTime("2025-07-15T00:09:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-15T00:13:00Z"), Valid: true}, + Description: sql.NullString{String: "Quick fix", Valid: true}, + DurationSeconds: 240, // 4 minutes + }, + }, + clientID: 1, + clientName: "Test Client", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + dateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + timezone: mustLoadLocation("America/New_York"), + wantEntries: 1, + wantHours: 0.0667, // 240 seconds / 3600 + }, + { + name: "unsupported entry type", + entries: "invalid", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateTimesheetData( + tt.entries, + tt.clientID, + tt.clientName, + tt.projectName, + tt.contractor, + tt.dateRange, + tt.timezone, + ) + + if tt.wantError { + if err == nil { + t.Errorf("GenerateTimesheetData() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("GenerateTimesheetData() error = %v", err) + return + } + + if len(result.Entries) != tt.wantEntries { + t.Errorf("GenerateTimesheetData() entries count = %d, want %d", len(result.Entries), tt.wantEntries) + } + + // Check total hours (with tolerance for floating point precision) + if abs(result.TotalHours-tt.wantHours) > 0.001 { + t.Errorf("GenerateTimesheetData() total hours = %f, want %f", result.TotalHours, tt.wantHours) + } + + // Check basic fields + if result.ClientID != tt.clientID { + t.Errorf("GenerateTimesheetData() client ID = %d, want %d", result.ClientID, tt.clientID) + } + if result.ClientName != tt.clientName { + t.Errorf("GenerateTimesheetData() client name = %s, want %s", result.ClientName, tt.clientName) + } + if result.ProjectName != tt.projectName { + t.Errorf("GenerateTimesheetData() project name = %s, want %s", result.ProjectName, tt.projectName) + } + + // Check timezone handling + if tt.timezone != nil { + expectedTimezone := tt.timezone.String() + if expectedTimezone == "Local" { + zone, _ := time.Now().Zone() + expectedTimezone = zone + } + if result.Timezone != expectedTimezone { + t.Errorf("GenerateTimesheetData() timezone = %s, want %s", result.Timezone, expectedTimezone) + } + } + }) + } +} + +func TestConvertToTimesheetEntries(t *testing.T) { + tests := []struct { + name string + entries []timesheetEntryData + timezone *time.Location + want []TimesheetEntry + }{ + { + name: "UTC timezone conversion", + entries: []timesheetEntryData{ + { + TimeEntryID: 1, + StartTime: mustParseTime("2025-07-10T14:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T18:05:00Z"), Valid: true}, + Description: sql.NullString{String: "Development work", Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 11400, // 3:10 + }, + }, + timezone: time.UTC, + want: []TimesheetEntry{ + { + Date: "2025-07-10", + StartTime: "14:55", + EndTime: "18:05", + Duration: "3:10", + Hours: 3.1667, // Rounded to nearest minute + ProjectName: "Test Project", + Description: "Development work", + }, + }, + }, + { + name: "timezone conversion to EST", + entries: []timesheetEntryData{ + { + TimeEntryID: 2, + StartTime: mustParseTime("2025-07-10T18:00:00Z"), // 6:00 PM UTC + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T19:00:00Z"), Valid: true}, // 7:00 PM UTC + Description: sql.NullString{String: "Meeting", Valid: true}, + DurationSeconds: 3600, // 1 hour + }, + }, + timezone: mustLoadLocation("America/New_York"), // UTC-4 in July (EDT) + want: []TimesheetEntry{ + { + Date: "2025-07-10", + StartTime: "14:00", // 2:00 PM EDT + EndTime: "15:00", // 3:00 PM EDT + Duration: "1:00", + Hours: 1.0, + ProjectName: "", + Description: "Meeting", + }, + }, + }, + { + name: "skip entry without end time", + entries: []timesheetEntryData{ + { + TimeEntryID: 3, + StartTime: mustParseTime("2025-07-10T14:55:00Z"), + EndTime: sql.NullTime{Valid: false}, // No end time + Description: sql.NullString{String: "Active entry", Valid: true}, + DurationSeconds: 0, + }, + }, + timezone: time.UTC, + want: []TimesheetEntry{}, // Should be empty + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertToTimesheetEntries(tt.entries, tt.timezone) + + if len(result) != len(tt.want) { + t.Errorf("convertToTimesheetEntries() length = %d, want %d", len(result), len(tt.want)) + return + } + + for i, entry := range result { + want := tt.want[i] + if entry.Date != want.Date { + t.Errorf("entry[%d].Date = %s, want %s", i, entry.Date, want.Date) + } + if entry.StartTime != want.StartTime { + t.Errorf("entry[%d].StartTime = %s, want %s", i, entry.StartTime, want.StartTime) + } + if entry.EndTime != want.EndTime { + t.Errorf("entry[%d].EndTime = %s, want %s", i, entry.EndTime, want.EndTime) + } + if entry.Duration != want.Duration { + t.Errorf("entry[%d].Duration = %s, want %s", i, entry.Duration, want.Duration) + } + if abs(entry.Hours-want.Hours) > 0.001 { + t.Errorf("entry[%d].Hours = %f, want %f", i, entry.Hours, want.Hours) + } + if entry.ProjectName != want.ProjectName { + t.Errorf("entry[%d].ProjectName = %s, want %s", i, entry.ProjectName, want.ProjectName) + } + if entry.Description != want.Description { + t.Errorf("entry[%d].Description = %s, want %s", i, entry.Description, want.Description) + } + } + }) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + seconds int64 + want string + }{ + {"zero", 0, "0:00"}, + {"30 seconds (rounds down)", 30, "0:01"}, // 30 seconds rounds to 1 minute + {"29 seconds (rounds down)", 29, "0:00"}, // 29 seconds rounds to 0 minutes + {"90 seconds", 90, "0:02"}, // 90 seconds rounds to 2 minutes + {"1 hour", 3600, "1:00"}, + {"1 hour 30 minutes", 5400, "1:30"}, + {"1 hour 29 minutes 59 seconds", 5399, "1:30"}, // Rounds to 1:30 + {"3 hours 10 minutes", 11400, "3:10"}, + {"large duration", 50000, "13:53"}, // 13 hours 53 minutes (rounded) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatDuration(tt.seconds) + if result != tt.want { + t.Errorf("formatDuration(%d) = %s, want %s", tt.seconds, result, tt.want) + } + }) + } +} + +func TestTimesheetTypstTemplateCompilation(t *testing.T) { + // Check if Typst is installed + if err := checkTypstInstalled(); err != nil { + t.Skip("Typst is not installed, skipping template compilation test") + } + + // Create temporary directory + tempDir, err := os.MkdirTemp("", "punchcard-timesheet-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + // Create test data file + testData := `{ + "client_name": "Test Client", + "project_name": "Test Project", + "date_range_start": "2025-07-01", + "date_range_end": "2025-07-31", + "generated_date": "2025-08-04", + "contractor_name": "Travis Parker", + "contractor_label": "Software Development", + "contractor_email": "travis@example.com", + "timezone": "UTC", + "entries": [ + { + "date": "2025-07-10", + "start_time": "14:55", + "end_time": "18:05", + "duration": "3:10", + "hours": 3.1667, + "project_name": "Test Project", + "description": "Development work" + }, + { + "date": "2025-07-10", + "start_time": "18:42", + "end_time": "20:04", + "duration": "1:22", + "hours": 1.3667, + "project_name": "Test Project", + "description": "Code review" + }, + { + "date": "2025-07-11", + "start_time": "13:55", + "end_time": "18:35", + "duration": "4:40", + "hours": 4.6667, + "project_name": "Test Project", + "description": "Feature implementation" + } + ], + "total_hours": 9.2 + }` + + dataFile := filepath.Join(tempDir, "data.json") + if err := os.WriteFile(dataFile, []byte(testData), 0644); err != nil { + t.Fatalf("Failed to write test data file: %v", err) + } + + // Write Typst template to temp directory + typstFile := filepath.Join(tempDir, "timesheet.typ") + if err := os.WriteFile(typstFile, []byte(templates.TimesheetTemplate), 0644); err != nil { + t.Fatalf("Failed to write Typst template: %v", err) + } + + // Compile with Typst + outputPDF := filepath.Join(tempDir, "test-timesheet.pdf") + cmd := exec.Command("typst", "compile", typstFile, outputPDF) + cmd.Dir = tempDir + + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Typst compilation failed: %v\nOutput: %s", err, string(output)) + } + + // Verify PDF was created + if _, err := os.Stat(outputPDF); os.IsNotExist(err) { + t.Fatalf("PDF file was not created") + } + + t.Logf("Successfully compiled timesheet Typst template to PDF") +} + +func TestGenerateTimesheetPDF(t *testing.T) { + // Check if Typst is installed + if err := checkTypstInstalled(); err != nil { + t.Skip("Typst is not installed, skipping PDF generation test") + } + + // Create test timesheet data + timesheetData := &TimesheetData{ + ClientName: "Test Client Co.", + ProjectName: "Test Project", + DateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + Entries: []TimesheetEntry{ + { + Date: "2025-07-10", + StartTime: "14:55", + EndTime: "18:05", + Duration: "3:10", + Hours: 3.1667, + ProjectName: "Test Project", + Description: "Development work", + }, + { + Date: "2025-07-10", + StartTime: "18:42", + EndTime: "20:04", + Duration: "1:22", + Hours: 1.3667, + ProjectName: "Test Project", + Description: "Code review", + }, + { + Date: "2025-07-11", + StartTime: "13:55", + EndTime: "18:35", + Duration: "4:40", + Hours: 4.6667, + ProjectName: "Test Project", + Description: "Feature implementation", + }, + }, + TotalHours: 9.2, + GeneratedDate: mustParseTime("2025-08-04T00:00:00Z"), + ContractorName: "Travis Parker", + ContractorLabel: "Software Development", + ContractorEmail: "travis@example.com", + Timezone: "UTC", + } + + // Create temporary output file + tempDir, err := os.MkdirTemp("", "punchcard-timesheet-pdf-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + outputPath := filepath.Join(tempDir, "test-timesheet.pdf") + + // Generate PDF + if err := GenerateTimesheetPDF(timesheetData, outputPath); err != nil { + t.Fatalf("Failed to generate timesheet PDF: %v", err) + } + + // Verify PDF was created + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + t.Fatalf("PDF file was not created at %s", outputPath) + } + + t.Logf("Successfully generated timesheet PDF at %s", outputPath) +} + +func TestGenerateDefaultTimesheetFilename(t *testing.T) { + dateRange := DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + } + + tests := []struct { + name string + clientName string + projectName string + want string + }{ + { + name: "client only", + clientName: "Test Client", + projectName: "", + want: "timesheet_Test Client_2025-07_", + }, + { + name: "client and project", + clientName: "Test Client", + projectName: "Test Project", + want: "timesheet_Test Client_Test Project_2025-07_", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateDefaultTimesheetFilename(tt.clientName, tt.projectName, dateRange) + + // Check that the filename starts with the expected pattern + if len(result) < len(tt.want) || result[:len(tt.want)] != tt.want { + t.Errorf("GenerateDefaultTimesheetFilename() prefix = %s, want prefix %s", result, tt.want) + } + + // Check that it ends with .pdf + if result[len(result)-4:] != ".pdf" { + t.Errorf("GenerateDefaultTimesheetFilename() should end with .pdf, got %s", result) + } + }) + } +} + +// Helper functions for tests +func mustParseTime(timeStr string) time.Time { + t, err := time.Parse(time.RFC3339, timeStr) + if err != nil { + panic(err) + } + return t +} + +func mustLoadLocation(name string) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + panic(err) + } + return loc +} + +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +}
\ No newline at end of file diff --git a/internal/reports/unified.go b/internal/reports/unified.go new file mode 100644 index 0000000..a6eb8c4 --- /dev/null +++ b/internal/reports/unified.go @@ -0,0 +1,83 @@ +package reports + +import ( + "fmt" + "time" + + "punchcard/internal/queries" +) + +type UnifiedReportData struct { + InvoiceData *InvoiceData + TimesheetData *TimesheetData +} + +func GenerateUnifiedReportData( + invoiceEntries interface{}, + clientID int64, + clientName, + projectName string, + contractor queries.Contractor, + invoiceNumber int64, + dateRange DateRange, + loc *time.Location, +) (*UnifiedReportData, error) { + // Generate invoice data + invoiceData, err := GenerateInvoiceData(invoiceEntries, clientID, clientName, projectName, contractor, invoiceNumber, dateRange) + if err != nil { + return nil, err + } + + // For timesheet data, we need to use the same entries but potentially different types + // Convert invoice entries to timesheet entries if needed + var timesheetEntries interface{} + switch e := invoiceEntries.(type) { + case []queries.GetInvoiceDataByClientRow: + // Convert to timesheet format + converted := make([]queries.GetTimesheetDataByClientRow, len(e)) + for i, entry := range e { + converted[i] = queries.GetTimesheetDataByClientRow{ + TimeEntryID: entry.TimeEntryID, + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + ClientID: entry.ClientID, + ClientName: entry.ClientName, + ProjectID: entry.ProjectID, + ProjectName: entry.ProjectName, + DurationSeconds: entry.DurationSeconds, + } + } + timesheetEntries = converted + case []queries.GetInvoiceDataByProjectRow: + // Convert to timesheet format + converted := make([]queries.GetTimesheetDataByProjectRow, len(e)) + for i, entry := range e { + converted[i] = queries.GetTimesheetDataByProjectRow{ + TimeEntryID: entry.TimeEntryID, + StartTime: entry.StartTime, + EndTime: entry.EndTime, + Description: entry.Description, + ClientID: entry.ClientID, + ClientName: entry.ClientName, + ProjectID: entry.ProjectID, + ProjectName: entry.ProjectName, + DurationSeconds: entry.DurationSeconds, + } + } + timesheetEntries = converted + default: + return nil, fmt.Errorf("unsupported entry type for unified report") + } + + // Generate timesheet data + timesheetData, err := GenerateTimesheetData(timesheetEntries, clientID, clientName, projectName, contractor, dateRange, loc) + if err != nil { + return nil, err + } + + return &UnifiedReportData{ + InvoiceData: invoiceData, + TimesheetData: timesheetData, + }, nil +}
\ No newline at end of file diff --git a/internal/reports/unified_test.go b/internal/reports/unified_test.go new file mode 100644 index 0000000..64d0b3f --- /dev/null +++ b/internal/reports/unified_test.go @@ -0,0 +1,562 @@ +package reports + +import ( + "database/sql" + "testing" + "time" + + "punchcard/internal/queries" +) + + +func TestGenerateUnifiedReportData(t *testing.T) { + tests := []struct { + name string + entries interface{} + clientID int64 + clientName string + projectName string + contractor queries.Contractor + invoiceNumber int64 + dateRange DateRange + timezone *time.Location + wantEntries int + wantHours float64 + wantTotalAmount float64 + wantError bool + }{ + { + name: "client entries with UTC timezone", + entries: []queries.GetInvoiceDataByClientRow{ + { + TimeEntryID: 1, + StartTime: mustParseTime("2025-07-10T14:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T18:05:00Z"), Valid: true}, + Description: sql.NullString{String: "GL closing", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 11400, // 3:10 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + { + TimeEntryID: 2, + StartTime: mustParseTime("2025-07-10T18:42:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T20:04:00Z"), Valid: true}, + Description: sql.NullString{String: "GL closing", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 4920, // 1:22 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + }, + clientID: 1, + clientName: "Test Client", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + invoiceNumber: 123, + dateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + timezone: time.UTC, + wantEntries: 1, // Both entries have same rate so grouped together + wantHours: 4.5333, // 16320 seconds / 3600 + wantTotalAmount: 6.80, // 4.5333 * 1.50 + }, + { + name: "project entries with local timezone", + entries: []queries.GetInvoiceDataByProjectRow{ + { + TimeEntryID: 3, + StartTime: mustParseTime("2025-07-11T13:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-11T18:35:00Z"), Valid: true}, + Description: sql.NullString{String: "Development work", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: 1, + ProjectName: "Test Project", + DurationSeconds: 16800, // 4:40 + EntryBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + RateSource: "entry", + }, + }, + clientID: 1, + clientName: "Test Client", + projectName: "Test Project", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + invoiceNumber: 124, + dateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + timezone: time.Local, + wantEntries: 1, + wantHours: 4.6667, // 16800 seconds / 3600 + wantTotalAmount: 5.83, // 4.6667 * 1.25 + }, + { + name: "entries with different timezone", + entries: []queries.GetInvoiceDataByClientRow{ + { + TimeEntryID: 4, + StartTime: mustParseTime("2025-07-15T00:09:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-15T00:13:00Z"), Valid: true}, + Description: sql.NullString{String: "Quick fix", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 240, // 4 minutes + EntryBillableRate: sql.NullInt64{Int64: 200, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 200, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 200, Valid: true}, + RateSource: "entry", + }, + }, + clientID: 1, + clientName: "Test Client", + contractor: queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + }, + invoiceNumber: 125, + dateRange: DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + }, + timezone: mustLoadLocation("America/New_York"), + wantEntries: 1, + wantHours: 0.0667, // 240 seconds / 3600 + wantTotalAmount: 0.13, // 0.0667 * 2.00 + }, + { + name: "unsupported entry type", + entries: "invalid", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateUnifiedReportData( + tt.entries, + tt.clientID, + tt.clientName, + tt.projectName, + tt.contractor, + tt.invoiceNumber, + tt.dateRange, + tt.timezone, + ) + + if tt.wantError { + if err == nil { + t.Errorf("GenerateUnifiedReportData() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("GenerateUnifiedReportData() error = %v", err) + return + } + + if result == nil { + t.Errorf("GenerateUnifiedReportData() returned nil result") + return + } + + // Test invoice data + if result.InvoiceData == nil { + t.Errorf("GenerateUnifiedReportData() invoice data is nil") + return + } + + if len(result.InvoiceData.LineItems) != tt.wantEntries { + t.Errorf("GenerateUnifiedReportData() invoice entries count = %d, want %d", len(result.InvoiceData.LineItems), tt.wantEntries) + } + + // Check invoice total hours (with tolerance for floating point precision) + if abs(result.InvoiceData.TotalHours-tt.wantHours) > 0.001 { + t.Errorf("GenerateUnifiedReportData() invoice total hours = %f, want %f", result.InvoiceData.TotalHours, tt.wantHours) + } + + // Check invoice total amount (with tolerance for floating point precision) + if abs(result.InvoiceData.TotalAmount-tt.wantTotalAmount) > 0.01 { + t.Errorf("GenerateUnifiedReportData() invoice total amount = %f, want %f", result.InvoiceData.TotalAmount, tt.wantTotalAmount) + } + + // Check invoice basic fields + if result.InvoiceData.ClientName != tt.clientName { + t.Errorf("GenerateUnifiedReportData() invoice client name = %s, want %s", result.InvoiceData.ClientName, tt.clientName) + } + if result.InvoiceData.ProjectName != tt.projectName { + t.Errorf("GenerateUnifiedReportData() invoice project name = %s, want %s", result.InvoiceData.ProjectName, tt.projectName) + } + if result.InvoiceData.InvoiceNumber != tt.invoiceNumber { + t.Errorf("GenerateUnifiedReportData() invoice number = %d, want %d", result.InvoiceData.InvoiceNumber, tt.invoiceNumber) + } + + // Test timesheet data + if result.TimesheetData == nil { + t.Errorf("GenerateUnifiedReportData() timesheet data is nil") + return + } + + // For timesheet, we expect individual entries, not grouped like invoice + expectedTimesheetEntries := tt.wantEntries + if tt.name == "client entries with UTC timezone" { + expectedTimesheetEntries = 2 // Individual timesheet entries + } + if len(result.TimesheetData.Entries) != expectedTimesheetEntries { + t.Errorf("GenerateUnifiedReportData() timesheet entries count = %d, want %d", len(result.TimesheetData.Entries), expectedTimesheetEntries) + } + + // Check timesheet total hours (with tolerance for floating point precision) + if abs(result.TimesheetData.TotalHours-tt.wantHours) > 0.001 { + t.Errorf("GenerateUnifiedReportData() timesheet total hours = %f, want %f", result.TimesheetData.TotalHours, tt.wantHours) + } + + // Check timesheet basic fields + if result.TimesheetData.ClientID != tt.clientID { + t.Errorf("GenerateUnifiedReportData() timesheet client ID = %d, want %d", result.TimesheetData.ClientID, tt.clientID) + } + if result.TimesheetData.ClientName != tt.clientName { + t.Errorf("GenerateUnifiedReportData() timesheet client name = %s, want %s", result.TimesheetData.ClientName, tt.clientName) + } + if result.TimesheetData.ProjectName != tt.projectName { + t.Errorf("GenerateUnifiedReportData() timesheet project name = %s, want %s", result.TimesheetData.ProjectName, tt.projectName) + } + + // Check timezone handling + if tt.timezone != nil { + expectedTimezone := tt.timezone.String() + if expectedTimezone == "Local" { + zone, _ := time.Now().Zone() + expectedTimezone = zone + } + if result.TimesheetData.Timezone != expectedTimezone { + t.Errorf("GenerateUnifiedReportData() timesheet timezone = %s, want %s", result.TimesheetData.Timezone, expectedTimezone) + } + } + + // Check contractor data consistency between invoice and timesheet + if result.InvoiceData.ContractorName != result.TimesheetData.ContractorName { + t.Errorf("GenerateUnifiedReportData() contractor name mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ContractorName, result.TimesheetData.ContractorName) + } + if result.InvoiceData.ContractorLabel != result.TimesheetData.ContractorLabel { + t.Errorf("GenerateUnifiedReportData() contractor label mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ContractorLabel, result.TimesheetData.ContractorLabel) + } + if result.InvoiceData.ContractorEmail != result.TimesheetData.ContractorEmail { + t.Errorf("GenerateUnifiedReportData() contractor email mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ContractorEmail, result.TimesheetData.ContractorEmail) + } + }) + } +} + +func TestUnifiedReportDataConsistency(t *testing.T) { + // Test that unified report produces consistent data between invoice and timesheet components + entries := []queries.GetInvoiceDataByClientRow{ + { + TimeEntryID: 1, + StartTime: mustParseTime("2025-07-10T14:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T18:05:00Z"), Valid: true}, + Description: sql.NullString{String: "Development work", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 11400, // 3:10 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + { + TimeEntryID: 2, + StartTime: mustParseTime("2025-07-10T18:42:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T20:04:00Z"), Valid: true}, + Description: sql.NullString{String: "Code review", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 4920, // 1:22 + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + } + + contractor := queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + } + + dateRange := DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + } + + result, err := GenerateUnifiedReportData( + entries, + 1, + "Test Client", + "Test Project", + contractor, + 123, + dateRange, + time.UTC, + ) + + if err != nil { + t.Fatalf("GenerateUnifiedReportData() error = %v", err) + } + + // Note: Invoice entries are grouped by rate, timesheet entries remain individual + // So we don't expect the counts to be equal - invoice will have 1 grouped item, timesheet will have 2 individual entries + if len(result.InvoiceData.LineItems) != 1 { + t.Errorf("Invoice line items count = %d, want 1 (grouped)", len(result.InvoiceData.LineItems)) + } + if len(result.TimesheetData.Entries) != 2 { + t.Errorf("Timesheet entries count = %d, want 2 (individual)", len(result.TimesheetData.Entries)) + } + + // Verify total hours consistency + if abs(result.InvoiceData.TotalHours-result.TimesheetData.TotalHours) > 0.001 { + t.Errorf("Total hours mismatch: invoice=%f, timesheet=%f", result.InvoiceData.TotalHours, result.TimesheetData.TotalHours) + } + + // Verify date range consistency + if !result.InvoiceData.DateRange.Start.Equal(result.TimesheetData.DateRange.Start) { + t.Errorf("Date range start mismatch: invoice=%v, timesheet=%v", result.InvoiceData.DateRange.Start, result.TimesheetData.DateRange.Start) + } + if !result.InvoiceData.DateRange.End.Equal(result.TimesheetData.DateRange.End) { + t.Errorf("Date range end mismatch: invoice=%v, timesheet=%v", result.InvoiceData.DateRange.End, result.TimesheetData.DateRange.End) + } + + // Verify client information consistency + if result.InvoiceData.ClientName != result.TimesheetData.ClientName { + t.Errorf("Client name mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ClientName, result.TimesheetData.ClientName) + } + + // Verify project information consistency + if result.InvoiceData.ProjectName != result.TimesheetData.ProjectName { + t.Errorf("Project name mismatch: invoice=%s, timesheet=%s", result.InvoiceData.ProjectName, result.TimesheetData.ProjectName) + } + + // Verify generation date consistency (should be the same day, allowing for small time differences) + invoiceGenTime := result.InvoiceData.GeneratedDate + timesheetGenTime := result.TimesheetData.GeneratedDate + if invoiceGenTime.Format("2006-01-02") != timesheetGenTime.Format("2006-01-02") { + t.Errorf("Generation date mismatch: invoice=%s, timesheet=%s", invoiceGenTime.Format("2006-01-02"), timesheetGenTime.Format("2006-01-02")) + } +} + +func TestUnifiedReportEntryTypeConversion(t *testing.T) { + tests := []struct { + name string + entries interface{} + expectError bool + }{ + { + name: "client entries conversion", + entries: []queries.GetInvoiceDataByClientRow{ + { + TimeEntryID: 1, + StartTime: mustParseTime("2025-07-10T14:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T18:05:00Z"), Valid: true}, + Description: sql.NullString{String: "Work", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: sql.NullInt64{Int64: 1, Valid: true}, + ProjectName: sql.NullString{String: "Test Project", Valid: true}, + DurationSeconds: 11400, + EntryBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 150, Valid: true}, + RateSource: "entry", + }, + }, + expectError: false, + }, + { + name: "project entries conversion", + entries: []queries.GetInvoiceDataByProjectRow{ + { + TimeEntryID: 2, + StartTime: mustParseTime("2025-07-11T13:55:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-11T18:35:00Z"), Valid: true}, + Description: sql.NullString{String: "Work", Valid: true}, + ClientID: 1, + ClientName: "Test Client", + ProjectID: 1, + ProjectName: "Test Project", + DurationSeconds: 16800, + EntryBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ClientBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + ProjectBillableRate: sql.NullInt64{Int64: 125, Valid: true}, + RateSource: "entry", + }, + }, + expectError: false, + }, + { + name: "unsupported entry type", + entries: []struct { + ID int + Name string + }{ + {ID: 1, Name: "Invalid"}, + }, + expectError: true, + }, + { + name: "nil entries", + entries: nil, + expectError: true, + }, + { + name: "string entries", + entries: "invalid", + expectError: true, + }, + } + + contractor := queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + } + + dateRange := DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateUnifiedReportData( + tt.entries, + 1, + "Test Client", + "Test Project", + contractor, + 123, + dateRange, + time.UTC, + ) + + if tt.expectError { + if err == nil { + t.Errorf("GenerateUnifiedReportData() expected error but got none") + } + if result != nil { + t.Errorf("GenerateUnifiedReportData() expected nil result on error, got %v", result) + } + } else { + if err != nil { + t.Errorf("GenerateUnifiedReportData() unexpected error = %v", err) + } + if result == nil { + t.Errorf("GenerateUnifiedReportData() expected result but got nil") + } + } + }) + } +} + +func TestUnifiedReportEmptyEntries(t *testing.T) { + contractor := queries.Contractor{ + Name: "Travis Parker", + Label: "Software Development", + Email: "travis@example.com", + } + + dateRange := DateRange{ + Start: mustParseTime("2025-07-01T00:00:00Z"), + End: mustParseTime("2025-07-31T23:59:59Z"), + } + + tests := []struct { + name string + entries interface{} + }{ + { + name: "empty client entries", + entries: []queries.GetInvoiceDataByClientRow{}, + }, + { + name: "empty project entries", + entries: []queries.GetInvoiceDataByProjectRow{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateUnifiedReportData( + tt.entries, + 1, + "Test Client", + "Test Project", + contractor, + 123, + dateRange, + time.UTC, + ) + + if err != nil { + t.Errorf("GenerateUnifiedReportData() error = %v", err) + return + } + + if result == nil { + t.Errorf("GenerateUnifiedReportData() returned nil result") + return + } + + // Both invoice and timesheet should have zero entries + if len(result.InvoiceData.LineItems) != 0 { + t.Errorf("GenerateUnifiedReportData() invoice entries count = %d, want 0", len(result.InvoiceData.LineItems)) + } + if len(result.TimesheetData.Entries) != 0 { + t.Errorf("GenerateUnifiedReportData() timesheet entries count = %d, want 0", len(result.TimesheetData.Entries)) + } + + // Both should have zero total hours + if result.InvoiceData.TotalHours != 0 { + t.Errorf("GenerateUnifiedReportData() invoice total hours = %f, want 0", result.InvoiceData.TotalHours) + } + if result.TimesheetData.TotalHours != 0 { + t.Errorf("GenerateUnifiedReportData() timesheet total hours = %f, want 0", result.TimesheetData.TotalHours) + } + + // Invoice should have zero total amount + if result.InvoiceData.TotalAmount != 0 { + t.Errorf("GenerateUnifiedReportData() invoice total amount = %f, want 0", result.InvoiceData.TotalAmount) + } + }) + } +}
\ No newline at end of file |