summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/commands/import.go4
-rw-r--r--internal/commands/report.go423
-rw-r--r--internal/commands/root.go2
-rw-r--r--internal/database/queries.sql53
-rw-r--r--internal/queries/queries.sql.go169
-rw-r--r--internal/reports/pdf.go203
-rw-r--r--internal/reports/pdf_test.go4
-rw-r--r--internal/reports/testdata/invoice_test_data.json6
-rw-r--r--internal/reports/timesheet.go185
-rw-r--r--internal/reports/timesheet_test.go541
-rw-r--r--internal/reports/unified.go83
-rw-r--r--internal/reports/unified_test.go562
-rw-r--r--june-unified.pdfbin0 -> 32700 bytes
-rw-r--r--templates/embeds.go6
-rw-r--r--templates/timesheet.typ163
15 files changed, 2390 insertions, 14 deletions
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
--- /dev/null
+++ b/june-unified.pdf
Binary files 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()