From dc895cec9d8a84af89ce2501db234dff33c757e2 Mon Sep 17 00:00:00 2001 From: T Date: Mon, 4 Aug 2025 15:34:23 -0600 Subject: timesheet and unified reports --- internal/commands/import.go | 4 +- internal/commands/report.go | 423 ++++++++++++++++- internal/commands/root.go | 2 +- internal/database/queries.sql | 53 +++ internal/queries/queries.sql.go | 169 +++++++ internal/reports/pdf.go | 203 +++++++- internal/reports/pdf_test.go | 4 +- internal/reports/testdata/invoice_test_data.json | 6 +- internal/reports/timesheet.go | 185 ++++++++ internal/reports/timesheet_test.go | 541 ++++++++++++++++++++++ internal/reports/unified.go | 83 ++++ internal/reports/unified_test.go | 562 +++++++++++++++++++++++ june-unified.pdf | Bin 0 -> 32700 bytes templates/embeds.go | 6 +- templates/timesheet.typ | 163 +++++++ 15 files changed, 2390 insertions(+), 14 deletions(-) create mode 100644 internal/reports/timesheet.go create mode 100644 internal/reports/timesheet_test.go create mode 100644 internal/reports/unified.go create mode 100644 internal/reports/unified_test.go create mode 100644 june-unified.pdf create mode 100644 templates/timesheet.typ diff --git a/internal/commands/import.go b/internal/commands/import.go index a1d0d8f..acbb0d6 100644 --- a/internal/commands/import.go +++ b/internal/commands/import.go @@ -65,7 +65,7 @@ For Clockify exports: cmd.Flags().StringP("timezone", "t", "Local", "Timezone of the CSV data (e.g., 'America/New_York', 'UTC', or 'Local')") cmd.Flags().StringP("source", "s", "", "Source format of the import file (supported: clockify)") - cmd.MarkFlagRequired("source") + _ = cmd.MarkFlagRequired("source") return cmd } @@ -85,7 +85,7 @@ func importClockifyCSV(queries *queries.Queries, filepath, timezone string) erro if err != nil { return fmt.Errorf("failed to open file: %w", err) } - defer file.Close() + defer func() { _ = file.Close() }() reader := csv.NewReader(file) records, err := reader.ReadAll() diff --git a/internal/commands/report.go b/internal/commands/report.go index 25e0483..0beb6ae 100644 --- a/internal/commands/report.go +++ b/internal/commands/report.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "path/filepath" + "time" punchctx "punchcard/internal/context" "punchcard/internal/database" @@ -23,6 +24,7 @@ func NewReportCmd() *cobra.Command { cmd.AddCommand(NewReportInvoiceCmd()) cmd.AddCommand(NewReportTimesheetCmd()) + cmd.AddCommand(NewReportUnifiedCmd()) return cmd } @@ -231,13 +233,426 @@ func generateProjectInvoice(q *queries.Queries, projectName string, dateRange re } func NewReportTimesheetCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "timesheet", Short: "Generate a PDF timesheet", - Long: "Generate a PDF timesheet report from tracked time.", + Long: `Generate a PDF timesheet report from tracked time. Either --client or --project must be specified. + +Examples: + # Generate timesheet for last month (default) + punch report timesheet -c "Acme Corp" + + # Generate timesheet for last week + punch report timesheet -c "Acme Corp" -d "last week" + + # Generate timesheet for custom date range + punch report timesheet -c "Acme Corp" -d "2025-06-01 to 2025-06-30" + + # Generate timesheet for specific project + punch report timesheet -p "Website Redesign" -d "2025-01-01 to 2025-01-31"`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Timesheet generation (placeholder)") - return nil + return runTimesheetCommand(cmd, args) }, } + + cmd.Flags().StringP("client", "c", "", "Generate timesheet for specific client") + cmd.Flags().StringP("project", "p", "", "Generate timesheet for specific project") + cmd.Flags().StringP("dates", "d", "last month", "Date range ('last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')") + cmd.Flags().StringP("output", "o", "", "Output file path (default: auto-generated filename)") + cmd.Flags().StringP("timezone", "t", "Local", "Timezone for displaying times (e.g., 'America/New_York', 'UTC', or 'Local')") + + return cmd +} + +func runTimesheetCommand(cmd *cobra.Command, args []string) error { + // Get flag values + clientName, _ := cmd.Flags().GetString("client") + projectName, _ := cmd.Flags().GetString("project") + dateStr, _ := cmd.Flags().GetString("dates") + outputPath, _ := cmd.Flags().GetString("output") + timezone, _ := cmd.Flags().GetString("timezone") + + // Validate flags + if clientName == "" && projectName == "" { + return fmt.Errorf("either --client or --project must be specified") + } + if clientName != "" && projectName != "" { + return fmt.Errorf("--client and --project are mutually exclusive") + } + + // Parse date range + dateRange, err := reports.ParseDateRange(dateStr) + if err != nil { + return fmt.Errorf("invalid date range: %w", err) + } + + // Parse timezone + var loc *time.Location + if timezone == "Local" { + loc = time.Local + } else { + loc, err = time.LoadLocation(timezone) + if err != nil { + return fmt.Errorf("invalid timezone '%s': %w", timezone, err) + } + } + + // Get database connection + q := punchctx.GetDB(cmd.Context()) + if q == nil { + var err error + q, err = database.GetDB() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + } + + // Generate timesheet based on client or project + var timesheetData *reports.TimesheetData + if clientName != "" { + timesheetData, err = generateClientTimesheet(q, clientName, dateRange, loc) + } else { + timesheetData, err = generateProjectTimesheet(q, projectName, dateRange, loc) + } + if err != nil { + return err + } + + // Generate output filename if not specified + if outputPath == "" { + outputPath = reports.GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, dateRange) + } + + // Convert to absolute path + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + // Generate PDF + err = reports.GenerateTimesheetPDF(timesheetData, outputPath) + if err != nil { + return fmt.Errorf("failed to generate timesheet PDF: %w", err) + } + + fmt.Printf("Timesheet generated successfully: %s\n", outputPath) + fmt.Printf("Total hours: %.2f\n", timesheetData.TotalHours) + fmt.Printf("Total entries: %d\n", len(timesheetData.Entries)) + + return nil +} + +func generateClientTimesheet(q *queries.Queries, clientName string, dateRange reports.DateRange, loc *time.Location) (*reports.TimesheetData, error) { + // Find client + client, err := findClient(context.Background(), q, clientName) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("client not found: %s", clientName) + } + return nil, fmt.Errorf("failed to find client: %w", err) + } + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get timesheet data + entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no completed time entries found for client %s in the specified date range", client.Name) + } + + return reports.GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, dateRange, loc) +} + +func generateProjectTimesheet(q *queries.Queries, projectName string, dateRange reports.DateRange, loc *time.Location) (*reports.TimesheetData, error) { + // Find project + project, err := findProject(context.Background(), q, projectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + // Get client info + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: project.ClientID, + Name: "", + }) + if err != nil || len(clients) == 0 { + return nil, fmt.Errorf("failed to find client for project") + } + client := clients[0] + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get timesheet data + entries, err := q.GetTimesheetDataByProject(context.Background(), queries.GetTimesheetDataByProjectParams{ + ProjectID: project.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", projectName) + } + + return reports.GenerateTimesheetData(entries, client.ID, client.Name, projectName, contractor, dateRange, loc) +} + +func NewReportUnifiedCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "unified", + Short: "Generate a unified PDF report (invoice + timesheet)", + Long: `Generate a unified PDF report combining invoice and timesheet on separate pages. Either --client or --project must be specified. + +Examples: + # Generate unified report for last month (default) + punch report unified -c "Acme Corp" + + # Generate unified report for last week + punch report unified -c "Acme Corp" -d "last week" + + # Generate unified report for custom date range + punch report unified -c "Acme Corp" -d "2025-06-01 to 2025-06-30" + + # Generate unified report for specific project + punch report unified -p "Website Redesign" -d "2025-01-01 to 2025-01-31"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runUnifiedCommand(cmd, args) + }, + } + + cmd.Flags().StringP("client", "c", "", "Generate unified report for specific client") + cmd.Flags().StringP("project", "p", "", "Generate unified report for specific project") + cmd.Flags().StringP("dates", "d", "last month", "Date range ('last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')") + cmd.Flags().StringP("output", "o", "", "Output file path (default: auto-generated filename)") + cmd.Flags().StringP("timezone", "t", "Local", "Timezone for displaying times (e.g., 'America/New_York', 'UTC', or 'Local')") + + return cmd +} + +func runUnifiedCommand(cmd *cobra.Command, args []string) error { + // Get flag values + clientName, _ := cmd.Flags().GetString("client") + projectName, _ := cmd.Flags().GetString("project") + dateStr, _ := cmd.Flags().GetString("dates") + outputPath, _ := cmd.Flags().GetString("output") + timezone, _ := cmd.Flags().GetString("timezone") + + // Validate flags + if clientName == "" && projectName == "" { + return fmt.Errorf("either --client or --project must be specified") + } + if clientName != "" && projectName != "" { + return fmt.Errorf("--client and --project are mutually exclusive") + } + + // Parse date range + dateRange, err := reports.ParseDateRange(dateStr) + if err != nil { + return fmt.Errorf("invalid date range: %w", err) + } + + // Parse timezone + var loc *time.Location + if timezone == "Local" { + loc = time.Local + } else { + loc, err = time.LoadLocation(timezone) + if err != nil { + return fmt.Errorf("invalid timezone '%s': %w", timezone, err) + } + } + + // Get database connection + q := punchctx.GetDB(cmd.Context()) + if q == nil { + var err error + q, err = database.GetDB() + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + } + + // Generate unified report based on client or project + var unifiedData *reports.UnifiedReportData + if clientName != "" { + unifiedData, err = generateClientUnified(q, clientName, dateRange, loc) + } else { + unifiedData, err = generateProjectUnified(q, projectName, dateRange, loc) + } + if err != nil { + return err + } + + // Generate output filename if not specified + if outputPath == "" { + outputPath = reports.GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, dateRange) + } + + // Convert to absolute path + outputPath, err = filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + // Generate PDF + err = reports.GenerateUnifiedPDF(unifiedData, outputPath) + if err != nil { + return fmt.Errorf("failed to generate unified PDF: %w", err) + } + + // Record invoice in database + if _, err := q.CreateInvoice(cmd.Context(), queries.CreateInvoiceParams{ + Year: int64(unifiedData.InvoiceData.DateRange.Start.Year()), + Month: int64(unifiedData.InvoiceData.DateRange.Start.Month()), + Number: unifiedData.InvoiceData.InvoiceNumber, + ClientID: unifiedData.InvoiceData.ClientID, + TotalAmount: int64(unifiedData.InvoiceData.TotalAmount * 100), + }); err != nil { + return fmt.Errorf("failed to record invoice in database: %w", err) + } + + fmt.Printf("Unified report generated successfully: %s\n", outputPath) + fmt.Printf("Invoice total: $%.2f (%d hours)\n", unifiedData.InvoiceData.TotalAmount, int(unifiedData.InvoiceData.TotalHours)) + fmt.Printf("Timesheet total: %.2f hours (%d entries)\n", unifiedData.TimesheetData.TotalHours, len(unifiedData.TimesheetData.Entries)) + + return nil +} + +func generateClientUnified(q *queries.Queries, clientName string, dateRange reports.DateRange, loc *time.Location) (*reports.UnifiedReportData, error) { + // Find client + client, err := findClient(context.Background(), q, clientName) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("client not found: %s", clientName) + } + return nil, fmt.Errorf("failed to find client: %w", err) + } + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get invoice number + highestNumber, err := q.GetHighestInvoiceNumber(context.Background(), queries.GetHighestInvoiceNumberParams{ + Year: int64(dateRange.Start.Year()), + Month: int64(dateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + // Get data for both invoice and timesheet + invoiceEntries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + timesheetEntries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(invoiceEntries) == 0 || len(timesheetEntries) == 0 { + return nil, fmt.Errorf("no completed time entries found for client %s in the specified date range", client.Name) + } + + return reports.GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, "", contractor, highestNumber+1, dateRange, loc) +} + +func generateProjectUnified(q *queries.Queries, projectName string, dateRange reports.DateRange, loc *time.Location) (*reports.UnifiedReportData, error) { + // Find project + project, err := findProject(context.Background(), q, projectName) + if err != nil { + return nil, fmt.Errorf("failed to find project: %w", err) + } + + // Get client info + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: project.ClientID, + Name: "", + }) + if err != nil || len(clients) == 0 { + return nil, fmt.Errorf("failed to find client for project") + } + client := clients[0] + + // Get contractor data + contractor, err := q.GetContractor(context.Background()) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no contractor information found - please add contractor details first") + } + return nil, fmt.Errorf("failed to get contractor information: %w", err) + } + + // Get invoice number + highestNumber, err := q.GetHighestInvoiceNumber(context.Background(), queries.GetHighestInvoiceNumberParams{ + Year: int64(dateRange.Start.Year()), + Month: int64(dateRange.Start.Month()), + }) + if err != nil { + return nil, fmt.Errorf("failed to get highest invoice number: %w", err) + } + + // Get data for both invoice and timesheet + invoiceEntries, err := q.GetInvoiceDataByProject(context.Background(), queries.GetInvoiceDataByProjectParams{ + ProjectID: project.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get invoice data: %w", err) + } + + timesheetEntries, err := q.GetTimesheetDataByProject(context.Background(), queries.GetTimesheetDataByProjectParams{ + ProjectID: project.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + return nil, fmt.Errorf("failed to get timesheet data: %w", err) + } + + if len(invoiceEntries) == 0 || len(timesheetEntries) == 0 { + return nil, fmt.Errorf("no completed time entries found for project %s in the specified date range", projectName) + } + + return reports.GenerateUnifiedReportData(invoiceEntries, client.ID, client.Name, projectName, contractor, highestNumber+1, dateRange, loc) } diff --git a/internal/commands/root.go b/internal/commands/root.go index 04f1203..553d0b4 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -37,7 +37,7 @@ func Execute() error { } defer func() { if db, ok := q.DBTX().(*sql.DB); ok { - db.Close() + _ = db.Close() } }() diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 32114a6..3a644b8 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -231,3 +231,56 @@ where year = @year and month = @month; insert into invoice (year, month, number, client_id, total_amount) values (@year, @month, @number, @client_id, @total_amount) returning *; + +-- name: GetTimesheetDataByClient :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast(round((julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60) as integer) as duration_seconds +from time_entry te +join client c on te.client_id = c.id +left join project p on te.project_id = p.id +where c.id = @client_id + and te.start_time >= @start_time + and te.start_time <= @end_time + and te.end_time is not null +order by te.start_time; + +-- name: GetTimesheetDataByProject :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast( + case + when te.end_time is null then + (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60 + else + (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60 + end as integer + ) as duration_seconds +from time_entry te +join client c on te.client_id = c.id +join project p on te.project_id = p.id +where p.id = @project_id + and te.start_time >= @start_time + and te.start_time <= @end_time + and te.end_time is not null +order by te.start_time; diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go index e70de22..dff9659 100644 --- a/internal/queries/queries.sql.go +++ b/internal/queries/queries.sql.go @@ -654,6 +654,175 @@ func (q *Queries) GetProjectByNameAndClient(ctx context.Context, arg GetProjectB return i, err } +const getTimesheetDataByClient = `-- name: GetTimesheetDataByClient :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast(round((julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60) as integer) as duration_seconds +from time_entry te +join client c on te.client_id = c.id +left join project p on te.project_id = p.id +where c.id = ?1 + and te.start_time >= ?2 + and te.start_time <= ?3 + and te.end_time is not null +order by te.start_time +` + +type GetTimesheetDataByClientParams struct { + ClientID int64 + StartTime time.Time + EndTime time.Time +} + +type GetTimesheetDataByClientRow struct { + TimeEntryID int64 + StartTime time.Time + EndTime sql.NullTime + Description sql.NullString + EntryBillableRate sql.NullInt64 + ClientID int64 + ClientName string + ClientBillableRate sql.NullInt64 + ProjectID sql.NullInt64 + ProjectName sql.NullString + ProjectBillableRate sql.NullInt64 + DurationSeconds int64 +} + +func (q *Queries) GetTimesheetDataByClient(ctx context.Context, arg GetTimesheetDataByClientParams) ([]GetTimesheetDataByClientRow, error) { + rows, err := q.db.QueryContext(ctx, getTimesheetDataByClient, arg.ClientID, arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTimesheetDataByClientRow + for rows.Next() { + var i GetTimesheetDataByClientRow + if err := rows.Scan( + &i.TimeEntryID, + &i.StartTime, + &i.EndTime, + &i.Description, + &i.EntryBillableRate, + &i.ClientID, + &i.ClientName, + &i.ClientBillableRate, + &i.ProjectID, + &i.ProjectName, + &i.ProjectBillableRate, + &i.DurationSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTimesheetDataByProject = `-- name: GetTimesheetDataByProject :many +select + te.id as time_entry_id, + te.start_time, + te.end_time, + te.description, + te.billable_rate as entry_billable_rate, + c.id as client_id, + c.name as client_name, + c.billable_rate as client_billable_rate, + p.id as project_id, + p.name as project_name, + p.billable_rate as project_billable_rate, + cast( + case + when te.end_time is null then + (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60 + else + (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60 + end as integer + ) as duration_seconds +from time_entry te +join client c on te.client_id = c.id +join project p on te.project_id = p.id +where p.id = ?1 + and te.start_time >= ?2 + and te.start_time <= ?3 + and te.end_time is not null +order by te.start_time +` + +type GetTimesheetDataByProjectParams struct { + ProjectID int64 + StartTime time.Time + EndTime time.Time +} + +type GetTimesheetDataByProjectRow struct { + TimeEntryID int64 + StartTime time.Time + EndTime sql.NullTime + Description sql.NullString + EntryBillableRate sql.NullInt64 + ClientID int64 + ClientName string + ClientBillableRate sql.NullInt64 + ProjectID int64 + ProjectName string + ProjectBillableRate sql.NullInt64 + DurationSeconds int64 +} + +func (q *Queries) GetTimesheetDataByProject(ctx context.Context, arg GetTimesheetDataByProjectParams) ([]GetTimesheetDataByProjectRow, error) { + rows, err := q.db.QueryContext(ctx, getTimesheetDataByProject, arg.ProjectID, arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTimesheetDataByProjectRow + for rows.Next() { + var i GetTimesheetDataByProjectRow + if err := rows.Scan( + &i.TimeEntryID, + &i.StartTime, + &i.EndTime, + &i.Description, + &i.EntryBillableRate, + &i.ClientID, + &i.ClientName, + &i.ClientBillableRate, + &i.ProjectID, + &i.ProjectName, + &i.ProjectBillableRate, + &i.DurationSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWeekSummaryByProject = `-- name: GetWeekSummaryByProject :many select p.id as project_id, 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 diff --git a/june-unified.pdf b/june-unified.pdf new file mode 100644 index 0000000..31897fe Binary files /dev/null and b/june-unified.pdf differ diff --git a/templates/embeds.go b/templates/embeds.go index 394a136..342912e 100644 --- a/templates/embeds.go +++ b/templates/embeds.go @@ -4,4 +4,8 @@ import _ "embed" // InvoiceTemplate contains the Typst invoice template //go:embed invoice.typ -var InvoiceTemplate string \ No newline at end of file +var InvoiceTemplate string + +// TimesheetTemplate contains the Typst timesheet template +//go:embed timesheet.typ +var TimesheetTemplate string \ No newline at end of file diff --git a/templates/timesheet.typ b/templates/timesheet.typ new file mode 100644 index 0000000..e888615 --- /dev/null +++ b/templates/timesheet.typ @@ -0,0 +1,163 @@ +#set page(margin: (top: 0.75in, bottom: 1in, left: 1in, right: 1in)) +#set text(font: ("EB Garamond", "Georgia"), size: 10pt) +#set par(leading: 0.65em) + +// Load timesheet data from JSON file +#let data = json("data.json") + +// Helper function to format hours as HH:MM with proper rounding +#let format-hours(hours) = { + let total-minutes = calc.round(hours * 60) + let h = calc.floor(total-minutes / 60) + let m = calc.rem(total-minutes, 60) + str(h) + ":" + if m < 10 { "0" + str(m) } else { str(m) } +} + +// Helper function to group entries by date +#let group-by-date(entries) = { + let groups = (:) + for entry in entries { + let date = entry.date + if date not in groups { + groups.insert(date, ()) + } + groups.at(date).push(entry) + } + groups +} + +// Professional header with company info +#let professional-header() = { + // Company header + align(left)[ + #text(size: 9pt, fill: gray)[ + #text(weight: "bold")[#data.contractor_name] • #data.contractor_label • #data.contractor_email + ] + ] + + v(3em) + + // Timesheet title + grid( + columns: (1fr, auto), + align(left)[ + #text(size: 28pt, weight: "bold")[Timesheet] + ], + align(right)[ + #text(size: 11pt)[ + #data.generated_date + ] + ] + ) + + v(2.5em) +} + +#let client-info-section() = { + grid( + columns: (1fr, 1fr), + gutter: 3em, + // Client section + [ + #text(size: 9pt, fill: gray)[CLIENT] + #v(0.5em) + #text(size: 12pt, weight: "bold")[#data.client_name] + #if data.project_name != "" [ + #v(0.3em) + #text(size: 10pt)[Project: #data.project_name] + ] + ], + // Period details + align(right)[ + #text(size: 9pt, fill: gray)[PERIOD] + #v(0.5em) + #text(size: 10pt)[#data.date_range_start to #data.date_range_end] + #v(0.3em) + #text(size: 9pt, fill: gray)[Times shown in: #data.timezone] + ] + ) + + v(2.5em) +} + +#let timesheet-table() = { + let grouped = group-by-date(data.entries) + let sorted-dates = grouped.keys().sorted() + + // Table header + table( + columns: (auto, auto, auto, auto, 1fr, 1fr), + stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { none }, + inset: (x: 8pt, y: 4pt), + align: (center, center, center, center, left, left), + column-gutter: 8pt, + + // Header row with extra vertical padding + table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[DATE]], + table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[START]], + table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[END]], + table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[DURATION]], + table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[PROJECT]], + table.cell(fill: rgb("#f8f9fa"), inset: (x: 8pt, y: 12pt))[#text(weight: "bold", size: 9pt)[DESCRIPTION]], + + // Data rows grouped by date + ..for date in sorted-dates { + let entries = grouped.at(date) + let daily-total = entries.map(entry => entry.hours).sum() + + // Create rows for this date + let date-rows = () + + // Add all entries for this date + for (i, entry) in entries.enumerate() { + let date-text = if i == 0 { date } else { "" } + + date-rows.push(( + text(size: 9pt, weight: "medium")[#date-text], + text(size: 9pt)[#entry.start_time], + text(size: 9pt)[#entry.end_time], + text(size: 9pt)[#entry.duration], + text(size: 9pt)[#entry.project_name], + text(size: 9pt)[#entry.description] + )) + } + + // Add daily subtotal row + date-rows.push(( + table.cell(colspan: 3, align: right)[#text(size: 9pt, weight: "medium", fill: gray)[Daily Total:]], + text(size: 9pt, weight: "medium")[#format-hours(daily-total)], + table.cell(colspan: 2)[] + )) + + // Add separator line after each day + date-rows.push(( + table.cell(colspan: 6, stroke: (top: 0.5pt + gray))[#v(0.1em)], + )) + + date-rows + }.flatten() + ) +} + +#let timesheet-summary() = { + v(1.5em) + + // Total hours section + align(right, + table( + columns: (auto, auto), + stroke: none, + inset: (x: 12pt, y: 6pt), + align: (right, right), + + table.hline(stroke: 0.5pt), + [#text(size: 12pt, weight: "bold")[Total Hours:]], [#text(size: 12pt, weight: "bold")[#format-hours(data.total_hours)]] + ) + ) +} + +// Main timesheet layout +#professional-header() +#client-info-section() +#timesheet-table() +#timesheet-summary() -- cgit v1.2.3