From add7c1a8126733dd86282f443dc53127888c06af Mon Sep 17 00:00:00 2001 From: T Date: Fri, 22 Aug 2025 15:52:06 -0600 Subject: loads of testing --- internal/commands/in_test.go | 187 +++++++++++++ internal/commands/out_test.go | 170 ++++++++++++ internal/database/queries_test.go | 441 +++++++++++++++++++++++++++++ internal/integration/timezone_test.go | 503 ++++++++++++++++++++++++++++++++++ internal/reports/daterange_test.go | 202 ++++++++++++++ internal/reports/timesheet_test.go | 200 ++++++++++++++ internal/tui/modal_test.go | 470 +++++++++++++++++++++++++++++++ internal/tui/shared_test.go | 403 +++++++++++++++++++++++++++ 8 files changed, 2576 insertions(+) create mode 100644 internal/database/queries_test.go create mode 100644 internal/integration/timezone_test.go create mode 100644 internal/tui/modal_test.go create mode 100644 internal/tui/shared_test.go (limited to 'internal') diff --git a/internal/commands/in_test.go b/internal/commands/in_test.go index 7d11a29..b0a0960 100644 --- a/internal/commands/in_test.go +++ b/internal/commands/in_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "strings" "testing" + "time" "git.tjp.lol/punchcard/internal/queries" ) @@ -564,3 +565,189 @@ func TestFindFunctions(t *testing.T) { }) } } + +func TestInCommandTimezoneHandling(t *testing.T) { + // Test that punch in commands store timestamps in UTC regardless of system timezone + tests := []struct { + name string + setupData func(*queries.Queries) (clientID int64) + args []string + expectError bool + }{ + { + name: "punch in stores UTC timestamp regardless of system timezone", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TimezoneTestClient", + }) + return client.ID + }, + args: []string{"in", "-c", "TimezoneTestClient", "Working across timezones"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + clientID := tt.setupData(q) + + // Execute command + _, err := executeCommandWithDB(t, q, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // Verify the time entry was created and has UTC timestamp + activeEntry, err := q.GetActiveTimeEntry(context.Background()) + if err != nil { + t.Fatalf("Failed to get active time entry: %v", err) + } + + // Verify that the stored timestamp is in UTC (Location should be UTC) + if activeEntry.StartTime.Location() != time.UTC { + t.Errorf("Expected start time to be in UTC, got location: %v", activeEntry.StartTime.Location()) + } + + // Verify client ID matches + if activeEntry.ClientID != clientID { + t.Errorf("Expected client ID %d, got %d", clientID, activeEntry.ClientID) + } + + // Verify description was stored + if !activeEntry.Description.Valid || activeEntry.Description.String != "Working across timezones" { + t.Errorf("Expected description 'Working across timezones', got %v", activeEntry.Description) + } + }) + } +} + +func TestInCommandDSTTransition(t *testing.T) { + // Test punch in behavior during DST transitions + // Note: This test focuses on verifying UTC storage, actual DST testing would require + // mocking system time which is complex with the current architecture + + tests := []struct { + name string + setupData func(*queries.Queries) (clientID int64) + args []string + }{ + { + name: "punch in during potential DST transition stores UTC", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "DSTTestClient", + }) + return client.ID + }, + args: []string{"in", "-c", "DSTTestClient", "DST transition work"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + clientID := tt.setupData(q) + + // Execute command + _, err := executeCommandWithDB(t, q, tt.args...) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify entry was created in UTC + activeEntry, err := q.GetActiveTimeEntry(context.Background()) + if err != nil { + t.Fatalf("Failed to get active time entry: %v", err) + } + + if activeEntry.StartTime.Location() != time.UTC { + t.Errorf("Expected UTC timezone, got: %v", activeEntry.StartTime.Location()) + } + + if activeEntry.ClientID != clientID { + t.Errorf("Expected client ID %d, got %d", clientID, activeEntry.ClientID) + } + }) + } +} + +func TestInCommandCopyFromRecentWithTimezone(t *testing.T) { + // Test that copying from most recent entry works correctly across timezone scenarios + tests := []struct { + name string + setupData func(*queries.Queries) error + args []string + }{ + { + name: "copy most recent entry maintains timezone independence", + setupData: func(q *queries.Queries) error { + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "RecentTestClient", + }) + if err != nil { + return err + } + + // Create and immediately stop a time entry to have a "most recent" + _, err = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Previous work", Valid: true}, + ClientID: client.ID, + }) + if err != nil { + return err + } + + _, err = q.StopTimeEntry(context.Background()) + return err + }, + args: []string{"in"}, // No flags - should copy most recent + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + if err := tt.setupData(q); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Execute command + output, err := executeCommandWithDB(t, q, tt.args...) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify it found and copied the recent entry + if !strings.Contains(output, "Started timer") || !strings.Contains(output, "RecentTestClient") { + t.Errorf("Expected output to indicate copying recent entry, got: %s", output) + } + + // Verify the new entry is stored in UTC + activeEntry, err := q.GetActiveTimeEntry(context.Background()) + if err != nil { + t.Fatalf("Failed to get active time entry: %v", err) + } + + if activeEntry.StartTime.Location() != time.UTC { + t.Errorf("Expected new entry to be in UTC, got: %v", activeEntry.StartTime.Location()) + } + }) + } +} diff --git a/internal/commands/out_test.go b/internal/commands/out_test.go index 03a9b73..2ea0d12 100644 --- a/internal/commands/out_test.go +++ b/internal/commands/out_test.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "errors" + "strings" "testing" + "time" "git.tjp.lol/punchcard/internal/queries" ) @@ -122,3 +124,171 @@ func TestOutCommandErrorType(t *testing.T) { } } +func TestOutCommandTimezoneHandling(t *testing.T) { + // Test that punch out stores UTC timestamps and calculates durations correctly + tests := []struct { + name string + setupData func(*queries.Queries) error + }{ + { + name: "punch out stores UTC end timestamp", + setupData: func(q *queries.Queries) error { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TimezoneOutClient", + }) + if err != nil { + return err + } + + _, err = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Timezone test work", Valid: true}, + ClientID: client.ID, + }) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + if err := tt.setupData(q); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Get the active entry before stopping to compare + beforeEntry, err := q.GetActiveTimeEntry(context.Background()) + if err != nil { + t.Fatalf("Failed to get active entry before stopping: %v", err) + } + + // Execute out command + output, err := executeCommandWithDB(t, q, "out") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify output contains expected elements + if !strings.Contains(output, "Timer stopped") { + t.Errorf("Expected 'Timer stopped' in output, got: %s", output) + } + + // Verify no active timer exists now + _, err = q.GetActiveTimeEntry(context.Background()) + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("Expected no active timer after stopping, but got: %v", err) + } + + // Get the stopped entry to verify timestamps + stoppedEntry, err := q.GetTimeEntryById(context.Background(), beforeEntry.ID) + if err != nil { + t.Fatalf("Failed to get stopped entry: %v", err) + } + + // Verify start time is still in UTC + if stoppedEntry.StartTime.Location() != time.UTC { + t.Errorf("Expected start time to remain in UTC, got: %v", stoppedEntry.StartTime.Location()) + } + + // Verify end time is stored in UTC + if !stoppedEntry.EndTime.Valid { + t.Errorf("Expected end time to be set") + } else if stoppedEntry.EndTime.Time.Location() != time.UTC { + t.Errorf("Expected end time to be in UTC, got: %v", stoppedEntry.EndTime.Time.Location()) + } + + // Verify end time is after or equal to start time (can be equal due to millisecond precision) + if stoppedEntry.EndTime.Valid && stoppedEntry.EndTime.Time.Before(stoppedEntry.StartTime) { + t.Errorf("Expected end time %v to be >= start time %v", stoppedEntry.EndTime.Time, stoppedEntry.StartTime) + } + }) + } +} + +func TestOutCommandDurationCalculation(t *testing.T) { + // Test that duration calculations are timezone-independent + tests := []struct { + name string + setupData func(*queries.Queries) error + }{ + { + name: "duration calculation works across timezone boundaries", + setupData: func(q *queries.Queries) error { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "DurationTestClient", + }) + if err != nil { + return err + } + + _, err = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Duration test work", Valid: true}, + ClientID: client.ID, + }) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + if err := tt.setupData(q); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Get start time + activeEntry, err := q.GetActiveTimeEntry(context.Background()) + if err != nil { + t.Fatalf("Failed to get active entry: %v", err) + } + startTime := activeEntry.StartTime + + // Wait a tiny bit to ensure non-zero duration (this is a simple test) + time.Sleep(time.Millisecond * 10) + + // Execute out command + output, err := executeCommandWithDB(t, q, "out") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Parse the duration from output (it should contain something like "Session duration: 10ms") + if !strings.Contains(output, "Session duration:") { + t.Errorf("Expected duration in output, got: %s", output) + } + + // Get the stopped entry + stoppedEntry, err := q.GetTimeEntryById(context.Background(), activeEntry.ID) + if err != nil { + t.Fatalf("Failed to get stopped entry: %v", err) + } + + // Verify that duration can be calculated correctly from UTC times + if !stoppedEntry.EndTime.Valid { + t.Fatalf("Expected end time to be valid") + } + + duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime) + if duration < 0 { + t.Errorf("Expected non-negative duration, got: %v", duration) + } + + // Duration should be at least the sleep time we waited (with some tolerance) + // Note: In fast tests, the duration might be very small or even 0 + if duration < 0 { + t.Errorf("Duration should not be negative, got: %v", duration) + } + + // Verify times are in UTC for consistency + if startTime.Location() != time.UTC || stoppedEntry.EndTime.Time.Location() != time.UTC { + t.Errorf("Expected both times in UTC for consistent duration calculation") + } + }) + } +} + diff --git a/internal/database/queries_test.go b/internal/database/queries_test.go new file mode 100644 index 0000000..435f8b0 --- /dev/null +++ b/internal/database/queries_test.go @@ -0,0 +1,441 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "git.tjp.lol/punchcard/internal/queries" + _ "modernc.org/sqlite" +) + +// setupTestDB creates an in-memory SQLite database for testing +func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open in-memory sqlite db: %v", err) + } + + // Initialize with the full schema + if err := InitializeDB(db); err != nil { + t.Fatalf("Failed to initialize database: %v", err) + } + + q := queries.New(db) + + cleanup := func() { + if err := db.Close(); err != nil { + t.Logf("error closing database: %v", err) + } + } + + return q, db, cleanup +} + +func TestSQLQueryTimezoneConsistency(t *testing.T) { + // Test that SQL queries handle timezone boundaries correctly + tests := []struct { + name string + setupData func(*sql.DB, int64) error + queryTest func(*queries.Queries, int64) error + }{ + { + name: "today summary query handles timezone boundaries", + setupData: func(db *sql.DB, clientID int64) error { + now := time.Now() + + // Insert entries for today, yesterday, and tomorrow in various timezone scenarios + entries := []struct { + start, end string + desc string + }{ + // Entry clearly in "today" regardless of timezone + {now.Add(-2 * time.Hour).Format("2006-01-02 15:04:05"), now.Add(-1 * time.Hour).Format("2006-01-02 15:04:05"), "today_clear"}, + + // Entry at the edge of today/yesterday boundary + {now.Format("2006-01-02") + " 00:30:00", now.Format("2006-01-02") + " 01:30:00", "today_early"}, + + // Entry at the edge of today/tomorrow boundary + {now.Format("2006-01-02") + " 22:30:00", now.Format("2006-01-02") + " 23:30:00", "today_late"}, + + // Entry from yesterday + {now.AddDate(0, 0, -1).Format("2006-01-02") + " 12:00:00", now.AddDate(0, 0, -1).Format("2006-01-02") + " 13:00:00", "yesterday"}, + } + + for _, entry := range entries { + _, err := db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, ?) + `, entry.start, entry.end, clientID, entry.desc) + if err != nil { + return err + } + } + return nil + }, + queryTest: func(q *queries.Queries, clientID int64) error { + // Test GetTodaySummary - returns total seconds as int64 + totalSeconds, err := q.GetTodaySummary(context.Background()) + if err != nil { + return err + } + + // We should get some seconds from "today" entries + // The exact amount depends on the test data, could be 0 if no entries match today + if totalSeconds < 0 { + t.Errorf("Expected non-negative total seconds, got %d", totalSeconds) + } + + t.Logf("GetTodaySummary returned %d total seconds", totalSeconds) + return nil + }, + }, + { + name: "week summary query handles week boundaries", + setupData: func(db *sql.DB, clientID int64) error { + now := time.Now() + + // Calculate this week's Monday + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // Sunday = 7 + } + thisMonday := now.AddDate(0, 0, -(weekday - 1)) + + entries := []struct { + start, end string + desc string + }{ + // Entry clearly in this week + {thisMonday.AddDate(0, 0, 1).Format("2006-01-02") + " 10:00:00", thisMonday.AddDate(0, 0, 1).Format("2006-01-02") + " 11:00:00", "this_week"}, + + // Entry from last week + {thisMonday.AddDate(0, 0, -2).Format("2006-01-02") + " 10:00:00", thisMonday.AddDate(0, 0, -2).Format("2006-01-02") + " 11:00:00", "last_week"}, + + // Entry at the week boundary (this Monday) + {thisMonday.Format("2006-01-02") + " 00:30:00", thisMonday.Format("2006-01-02") + " 01:30:00", "week_boundary"}, + } + + for _, entry := range entries { + _, err := db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, ?) + `, entry.start, entry.end, clientID, entry.desc) + if err != nil { + return err + } + } + return nil + }, + queryTest: func(q *queries.Queries, clientID int64) error { + // Test GetWeekSummaryByProject + summaryRows, err := q.GetWeekSummaryByProject(context.Background()) + if err != nil { + return err + } + + // Should get entries from this week + if len(summaryRows) == 0 { + t.Logf("Warning: GetWeekSummaryByProject returned no entries") + } + + for _, s := range summaryRows { + if s.TotalSeconds <= 0 { + t.Errorf("Expected positive duration, got %d", s.TotalSeconds) + } + } + + return nil + }, + }, + { + name: "mixed timezone modifiers work correctly", + setupData: func(db *sql.DB, clientID int64) error { + // Test the mixed 'localtime' and 'utc' usage in queries + now := time.Now() + + // Insert entries that test the boundary conditions of mixed timezone usage + _, err := db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) VALUES + (?, ?, ?, 'boundary_test_1'), + (?, ?, ?, 'boundary_test_2') + `, + now.Format("2006-01-02 15:04:05"), now.Add(time.Hour).Format("2006-01-02 15:04:05"), clientID, + now.Add(-24*time.Hour).Format("2006-01-02 15:04:05"), now.Add(-23*time.Hour).Format("2006-01-02 15:04:05"), clientID, + ) + return err + }, + queryTest: func(q *queries.Queries, clientID int64) error { + // Test both today and week summaries to ensure consistent timezone handling + todayTotal, err := q.GetTodaySummary(context.Background()) + if err != nil { + return err + } + + weekSummaryRows, err := q.GetWeekSummaryByProject(context.Background()) + if err != nil { + return err + } + + // Calculate week total from summary rows + var weekTotal int64 + for _, s := range weekSummaryRows { + weekTotal += s.TotalSeconds + } + + // Week total should be >= today total (assuming both have entries) + if weekTotal > 0 && todayTotal > weekTotal { + t.Errorf("Week total (%d) should be >= today total (%d)", weekTotal, todayTotal) + } + + t.Logf("Today total: %d seconds, Week total: %d seconds", todayTotal, weekTotal) + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Create test client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Setup test data + if err := tt.setupData(db, client.ID); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Run the query test + if err := tt.queryTest(q, client.ID); err != nil { + t.Fatalf("Query test failed: %v", err) + } + }) + } +} + +func TestTimezoneQueryBoundaryEdgeCases(t *testing.T) { + // Test specific edge cases around timezone boundaries + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Create test client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "EdgeCaseClient", + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Test case: entry that starts in one day (UTC) but is in another day (local) + // This tests the edge case where localtime and UTC disagree about the date + now := time.Now() + + // Find a time where local and UTC dates might differ + // For example, if it's currently 2 AM local but 8 AM UTC (in a UTC+6 timezone) + testTime := time.Date(now.Year(), now.Month(), now.Day(), 1, 0, 0, 0, time.Local) // 1 AM local + utcTestTime := testTime.UTC() + + // Insert entry at this boundary time + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'boundary_edge_case') + `, testTime.Format("2006-01-02 15:04:05"), testTime.Add(time.Hour).Format("2006-01-02 15:04:05"), client.ID) + if err != nil { + t.Fatalf("Failed to insert test entry: %v", err) + } + + t.Logf("Test entry: Local time %s, UTC time %s", testTime.Format("2006-01-02 15:04:05"), utcTestTime.Format("2006-01-02 15:04:05")) + + // Test today summary + todaySummary, err := q.GetTodaySummary(context.Background()) + if err != nil { + // GetTodaySummary might fail if there are no entries for today (returns NULL) + t.Logf("GetTodaySummary failed (likely no entries for today): %v", err) + return + } + + // The entry should appear in today's summary if the query uses localtime correctly + // Note: GetTodaySummary returns total seconds, so we just check if it's positive + found := todaySummary > 0 + + // Log result for manual verification - exact behavior depends on current timezone + t.Logf("Today summary %s entry for boundary case (local: %s, UTC: %s, total seconds: %d)", + map[bool]string{true: "found", false: "did not find"}[found], + testTime.Format("2006-01-02"), utcTestTime.Format("2006-01-02"), todaySummary) +} + +func TestSQLiteTimezoneFunction(t *testing.T) { + // Test that SQLite's timezone functions work as expected + _, db, cleanup := setupTestDB(t) + defer cleanup() + + tests := []struct { + name string + query string + expected string // Expected format or pattern + }{ + { + name: "datetime now UTC", + query: "SELECT datetime('now', 'utc')", + expected: "UTC timestamp format", + }, + { + name: "datetime now localtime", + query: "SELECT datetime('now', 'localtime')", + expected: "Local timestamp format", + }, + { + name: "date conversion from UTC to localtime", + query: "SELECT date('2024-08-22 12:00:00', 'localtime')", + expected: "Date format", + }, + { + name: "weekday calculation", + query: "SELECT date('now', 'localtime', 'weekday 0', '-6 days')", + expected: "Monday of this week", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result string + err := db.QueryRow(tt.query).Scan(&result) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + + // Verify result is not empty and has reasonable format + if result == "" { + t.Errorf("Expected non-empty result for %s", tt.name) + } + + t.Logf("%s result: %s", tt.name, result) + + // Basic format validation + switch tt.name { + case "datetime now UTC", "datetime now localtime": + // Should be YYYY-MM-DD HH:MM:SS format + if _, err := time.Parse("2006-01-02 15:04:05", result); err != nil { + t.Errorf("Result %s not in expected datetime format: %v", result, err) + } + case "date conversion from UTC to localtime": + // Should be YYYY-MM-DD format + if _, err := time.Parse("2006-01-02", result); err != nil { + t.Errorf("Result %s not in expected date format: %v", result, err) + } + case "weekday calculation": + // Should be a date, and should be a Monday + date, err := time.Parse("2006-01-02", result) + if err != nil { + t.Errorf("Result %s not in expected date format: %v", result, err) + } else if date.Weekday() != time.Monday { + t.Errorf("Expected Monday, got %v for result %s", date.Weekday(), result) + } + } + }) + } +} + +func TestQueryResultTimezoneConsistency(t *testing.T) { + // Test that queries return timestamps in a consistent format + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Create test data + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ConsistencyTestClient", + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Insert entry with known UTC time + utcTime := "2024-08-22 14:30:00" + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'consistency_test') + `, utcTime, "2024-08-22 16:30:00", client.ID) + if err != nil { + t.Fatalf("Failed to insert test entry: %v", err) + } + + // Test different query methods return consistent timestamp formats + tests := []struct { + name string + queryFunc func() (time.Time, error) + }{ + { + name: "GetMostRecentTimeEntry", + queryFunc: func() (time.Time, error) { + entry, err := q.GetMostRecentTimeEntry(context.Background()) + if err != nil { + return time.Time{}, err + } + return entry.StartTime, nil + }, + }, + { + name: "Direct SQL query", + queryFunc: func() (time.Time, error) { + var startTime string + err := db.QueryRow("SELECT start_time FROM time_entry ORDER BY id DESC LIMIT 1").Scan(&startTime) + if err != nil { + return time.Time{}, err + } + // Parse as stored format - try different formats since SQLite might return different formats + formats := []string{ + "2006-01-02 15:04:05", // Standard format + "2006-01-02T15:04:05Z", // ISO format with Z + "2006-01-02T15:04:05", // ISO format without Z + time.RFC3339, // RFC3339 format + } + for _, format := range formats { + if t, err := time.Parse(format, startTime); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unable to parse time %q with any known format", startTime) + }, + }, + } + + var timestamps []time.Time + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timestamp, err := tt.queryFunc() + if err != nil { + t.Fatalf("Query failed: %v", err) + } + + timestamps = append(timestamps, timestamp) + + // Verify timestamp is not zero + if timestamp.IsZero() { + t.Errorf("Got zero timestamp from %s", tt.name) + } + + t.Logf("%s returned: %s (location: %v)", tt.name, timestamp.Format("2006-01-02 15:04:05"), timestamp.Location()) + }) + } + + // Verify all queries returned equivalent times (accounting for timezone differences) + if len(timestamps) >= 2 { + first := timestamps[0].UTC() + for i, ts := range timestamps[1:] { + second := ts.UTC() + if !first.Equal(second) { + t.Errorf("Timestamp inconsistency: query 0 returned %v, query %d returned %v (both in UTC)", first, i+1, second) + } + } + } +} + diff --git a/internal/integration/timezone_test.go b/internal/integration/timezone_test.go new file mode 100644 index 0000000..695fb04 --- /dev/null +++ b/internal/integration/timezone_test.go @@ -0,0 +1,503 @@ +package integration + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.tjp.lol/punchcard/internal/database" + "git.tjp.lol/punchcard/internal/queries" + "git.tjp.lol/punchcard/internal/reports" + _ "modernc.org/sqlite" +) + +// setupIntegrationDB creates a full database setup for integration testing +func setupIntegrationDB(t *testing.T) (*queries.Queries, func()) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open in-memory sqlite db: %v", err) + } + + if err := database.InitializeDB(db); err != nil { + t.Fatalf("Failed to initialize database: %v", err) + } + + q := queries.New(db) + + cleanup := func() { + if err := db.Close(); err != nil { + t.Logf("error closing database: %v", err) + } + } + + return q, cleanup +} + +func TestEndToEndTimezoneWorkflow(t *testing.T) { + // Integration test: complete workflow from punch in/out to report generation + // with timezone consistency verification + + q, cleanup := setupIntegrationDB(t) + defer cleanup() + + // Setup: Create client and contractor + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "E2E Test Client", + Email: sql.NullString{String: "test@client.com", Valid: true}, + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + contractor, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{ + Name: "E2E Contractor", + Label: "Software Development", + Email: "contractor@example.com", + }) + if err != nil { + t.Fatalf("Failed to create contractor: %v", err) + } + + // Step 1: Create time entries with specific times for predictable testing + // Use a known time range to make verification easier + baseTime := time.Date(2024, time.August, 22, 14, 0, 0, 0, time.UTC) + + // Create time entries that span different timezone scenarios + entries := []struct { + start, end time.Time + desc string + }{ + {baseTime, baseTime.Add(2 * time.Hour), "Morning work"}, + {baseTime.Add(4 * time.Hour), baseTime.Add(6 * time.Hour), "Afternoon work"}, + {baseTime.AddDate(0, 0, 1), baseTime.AddDate(0, 0, 1).Add(3 * time.Hour), "Next day work"}, + } + + var entryIDs []int64 + for _, entry := range entries { + // Insert entries directly with specific times for predictable testing + result, err := q.DBTX().(*sql.DB).Exec(` + INSERT INTO time_entry (start_time, end_time, description, client_id) + VALUES (?, ?, ?, ?) + `, entry.start.Format("2006-01-02 15:04:05"), entry.end.Format("2006-01-02 15:04:05"), entry.desc, client.ID) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + + entryID, err := result.LastInsertId() + if err != nil { + t.Fatalf("Failed to get entry ID: %v", err) + } + + entryIDs = append(entryIDs, entryID) + } + + // Step 2: Test report generation in different timezones + testTimezones := []*time.Location{ + time.UTC, + time.Local, + } + + // Try to load some interesting timezones for testing + extraTimezones := []string{ + "America/New_York", + "America/Los_Angeles", + "Asia/Tokyo", + "Europe/London", + } + + for _, tzName := range extraTimezones { + if tz, err := time.LoadLocation(tzName); err == nil { + testTimezones = append(testTimezones, tz) + } else { + t.Logf("Skipping timezone %s: %v", tzName, err) + } + } + + for _, tz := range testTimezones { + t.Run("timezone_"+tz.String(), func(t *testing.T) { + // Test timesheet generation + dateRange := reports.DateRange{ + Start: baseTime.AddDate(0, 0, -1), // Day before to capture all entries + End: baseTime.AddDate(0, 0, 2), // Day after to capture all entries + } + + // Generate timesheet data + timesheetEntries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + t.Fatalf("Failed to get timesheet data: %v", err) + } + + if len(timesheetEntries) == 0 { + t.Fatalf("No timesheet entries found - date filtering may be incorrect") + } + + timesheetData, err := reports.GenerateTimesheetData( + timesheetEntries, + client.ID, + client.Name, + "", + contractor, + dateRange, + tz, + ) + if err != nil { + t.Fatalf("Failed to generate timesheet data: %v", err) + } + + // Verify timezone consistency + if len(timesheetData.Entries) != len(entries) { + t.Errorf("Expected %d entries, got %d in timezone %s", len(entries), len(timesheetData.Entries), tz) + } + + // Verify that total hours calculation is consistent regardless of timezone + expectedTotalHours := 7.0 // 2 + 2 + 3 hours from our test data + if timesheetData.TotalHours != expectedTotalHours { + t.Errorf("Expected total hours %v, got %v in timezone %s", expectedTotalHours, timesheetData.TotalHours, tz) + } + + // Verify timezone field is set correctly + expectedTimezoneStr := tz.String() + if expectedTimezoneStr == "Local" { + zone, _ := time.Now().Zone() + expectedTimezoneStr = zone + } + + if timesheetData.Timezone != expectedTimezoneStr { + t.Errorf("Expected timezone %s, got %s", expectedTimezoneStr, timesheetData.Timezone) + } + + // Verify individual entry times are converted to the target timezone + for i, entry := range timesheetData.Entries { + // Verify date is in the target timezone context + originalStart := entries[i].start + expectedDate := originalStart.In(tz).Format("2006-01-02") + + if entry.Date != expectedDate { + t.Errorf("Entry %d: expected date %s, got %s (timezone %s)", i, expectedDate, entry.Date, tz) + } + + t.Logf("Timezone %s: Entry %d on %s (%s-%s) = %v hours", + tz, i, entry.Date, entry.StartTime, entry.EndTime, entry.Hours) + } + }) + } +} + +func TestCrossTImezoneReportConsistency(t *testing.T) { + // Test that the same data produces consistent reports across different timezones + // (with appropriate timezone conversions for display) + + q, cleanup := setupIntegrationDB(t) + defer cleanup() + + // Setup test data + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Consistency Test Client", + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + contractor, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{ + Name: "Test Contractor", + Label: "Development", + Email: "test@example.com", + }) + if err != nil { + t.Fatalf("Failed to create contractor: %v", err) + } + + // Create a time entry that spans multiple hours for clear testing + startTime := time.Date(2024, time.August, 22, 14, 30, 0, 0, time.UTC) // 2:30 PM UTC + endTime := startTime.Add(3*time.Hour + 30*time.Minute) // 6:00 PM UTC + + _, err = q.DBTX().(*sql.DB).Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'Cross-timezone test work') + `, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), client.ID) + if err != nil { + t.Fatalf("Failed to create test entry: %v", err) + } + + // Test in multiple timezones + timezones := []*time.Location{time.UTC} + tzNames := []string{"America/New_York", "Asia/Tokyo", "Australia/Sydney"} + + for _, tzName := range tzNames { + if tz, err := time.LoadLocation(tzName); err == nil { + timezones = append(timezones, tz) + } + } + + dateRange := reports.DateRange{ + Start: startTime.AddDate(0, 0, -1), + End: startTime.AddDate(0, 0, 1), + } + + var timesheetDatas []*reports.TimesheetData + for _, tz := range timezones { + // Get entries for this timezone + entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + t.Fatalf("Failed to get entries for timezone %s: %v", tz, err) + } + + // Generate timesheet data + data, err := reports.GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, dateRange, tz) + if err != nil { + t.Fatalf("Failed to generate timesheet data for timezone %s: %v", tz, err) + } + + timesheetDatas = append(timesheetDatas, data) + } + + // Verify consistency across timezones + if len(timesheetDatas) < 2 { + t.Skip("Need at least 2 timezones for consistency testing") + } + + firstData := timesheetDatas[0] + for i, data := range timesheetDatas[1:] { + // Total hours should be the same regardless of timezone + if data.TotalHours != firstData.TotalHours { + t.Errorf("Timezone %d: total hours %v != baseline %v", i+1, data.TotalHours, firstData.TotalHours) + } + + // Should have same number of entries + if len(data.Entries) != len(firstData.Entries) { + t.Errorf("Timezone %d: entry count %d != baseline %d", i+1, len(data.Entries), len(firstData.Entries)) + } + + // Each entry should have same duration (hours) + for j, entry := range data.Entries { + if j < len(firstData.Entries) { + if entry.Hours != firstData.Entries[j].Hours { + t.Errorf("Timezone %d, entry %d: hours %v != baseline %v", i+1, j, entry.Hours, firstData.Entries[j].Hours) + } + } + } + + t.Logf("Timezone %s: %d entries, %.2f total hours", data.Timezone, len(data.Entries), data.TotalHours) + } +} + +func TestTimezoneFilteringEdgeCases(t *testing.T) { + // Test edge cases where timezone differences could affect filtering + q, cleanup := setupIntegrationDB(t) + defer cleanup() + + // Create test client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Edge Case Client", + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Test scenario: entry that occurs on different calendar dates in different timezones + // e.g., 11 PM Pacific on July 31 = 6 AM UTC on August 1 + + // Create entry at midnight UTC (edge of day boundary) + edgeTime := time.Date(2024, time.August, 1, 0, 30, 0, 0, time.UTC) // 12:30 AM UTC on Aug 1 + + _, err = q.DBTX().(*sql.DB).Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'Timezone edge case work') + `, edgeTime.Format("2006-01-02 15:04:05"), edgeTime.Add(time.Hour).Format("2006-01-02 15:04:05"), client.ID) + if err != nil { + t.Fatalf("Failed to create edge case entry: %v", err) + } + + // Test filtering with different timezone contexts + testCases := []struct { + name string + timezone string + expectFind bool // Whether we expect to find the entry in July or August reports + monthYear string + }{ + {"UTC August", "UTC", true, "august 2024"}, + {"UTC July", "UTC", false, "july 2024"}, + // Note: The actual filtering is done at database level using UTC dates, + // not timezone-aware dates. So the entry at UTC 2024-08-01 00:30:00 + // will appear in August reports regardless of target timezone + {"Pacific July", "America/Los_Angeles", false, "july 2024"}, // UTC date is August, not July + {"Pacific August", "America/Los_Angeles", true, "august 2024"}, // UTC date is August + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var tz *time.Location + if tc.timezone == "UTC" { + tz = time.UTC + } else { + var err error + tz, err = time.LoadLocation(tc.timezone) + if err != nil { + t.Skipf("Timezone %s not available: %v", tc.timezone, err) + } + } + + // Parse the month range + dateRange, err := reports.ParseDateRange(tc.monthYear) + if err != nil { + t.Fatalf("Failed to parse date range %s: %v", tc.monthYear, err) + } + + // Get entries for this month and timezone + entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + t.Fatalf("Failed to get entries: %v", err) + } + + found := len(entries) > 0 + + if found != tc.expectFind { + t.Errorf("Expected to find entry: %v, actually found: %v (timezone: %s, month: %s)", + tc.expectFind, found, tc.timezone, tc.monthYear) + + // Log the actual time in both timezones for debugging + utcTime := edgeTime + localTime := edgeTime.In(tz) + t.Logf("Entry time: UTC=%s, %s=%s", + utcTime.Format("2006-01-02 15:04:05"), + tc.timezone, + localTime.Format("2006-01-02 15:04:05")) + } + }) + } +} + +func TestReportGenerationTimezoneAccuracy(t *testing.T) { + // Verify that report generation maintains accuracy across timezone conversions + q, cleanup := setupIntegrationDB(t) + defer cleanup() + + // Setup comprehensive test data + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "Accuracy Test Client", + BillableRate: sql.NullInt64{Int64: 15000, Valid: true}, // $150.00/hour + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + contractor, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{ + Name: "Accuracy Contractor", + Label: "Testing", + Email: "accuracy@test.com", + }) + if err != nil { + t.Fatalf("Failed to create contractor: %v", err) + } + + // Create entries with precise timing for accurate verification + preciseEntries := []struct { + start time.Time + duration time.Duration + desc string + }{ + {time.Date(2024, time.August, 22, 9, 0, 0, 0, time.UTC), 2*time.Hour + 30*time.Minute, "Morning session"}, + {time.Date(2024, time.August, 22, 13, 15, 0, 0, time.UTC), 1*time.Hour + 45*time.Minute, "Afternoon session"}, + {time.Date(2024, time.August, 23, 10, 30, 0, 0, time.UTC), 3 * time.Hour, "Next day session"}, + } + + var totalExpectedSeconds int64 + for _, entry := range preciseEntries { + endTime := entry.start.Add(entry.duration) + totalExpectedSeconds += int64(entry.duration.Seconds()) + + _, err = q.DBTX().(*sql.DB).Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, ?) + `, entry.start.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), client.ID, entry.desc) + if err != nil { + t.Fatalf("Failed to create precision entry: %v", err) + } + } + + expectedTotalHours := float64(totalExpectedSeconds) / 3600.0 + + // Test accuracy across different timezones + testTimezones := []string{"UTC", "America/New_York", "Asia/Tokyo"} + + for _, tzName := range testTimezones { + t.Run("accuracy_"+tzName, func(t *testing.T) { + var tz *time.Location + if tzName == "UTC" { + tz = time.UTC + } else { + var err error + tz, err = time.LoadLocation(tzName) + if err != nil { + t.Skipf("Timezone %s not available: %v", tzName, err) + } + } + + // Generate timesheet + dateRange := reports.DateRange{ + Start: time.Date(2024, time.August, 22, 0, 0, 0, 0, time.UTC), + End: time.Date(2024, time.August, 23, 23, 59, 59, 999999999, time.UTC), + } + + entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: client.ID, + StartTime: dateRange.Start, + EndTime: dateRange.End, + }) + if err != nil { + t.Fatalf("Failed to get entries: %v", err) + } + + timesheetData, err := reports.GenerateTimesheetData(entries, client.ID, client.Name, "", contractor, dateRange, tz) + if err != nil { + t.Fatalf("Failed to generate timesheet: %v", err) + } + + // Verify total hours accuracy (within small tolerance for rounding) + tolerance := 0.001 + if abs(timesheetData.TotalHours-expectedTotalHours) > tolerance { + t.Errorf("Total hours accuracy error in timezone %s: expected %.6f, got %.6f (diff: %.6f)", + tzName, expectedTotalHours, timesheetData.TotalHours, abs(timesheetData.TotalHours-expectedTotalHours)) + } + + // Verify individual entry accuracy + if len(timesheetData.Entries) != len(preciseEntries) { + t.Errorf("Entry count mismatch in timezone %s: expected %d, got %d", tzName, len(preciseEntries), len(timesheetData.Entries)) + } + + for i, entry := range timesheetData.Entries { + if i < len(preciseEntries) { + expectedHours := preciseEntries[i].duration.Hours() + if abs(entry.Hours-expectedHours) > tolerance { + t.Errorf("Entry %d hours accuracy error in timezone %s: expected %.6f, got %.6f", + i, tzName, expectedHours, entry.Hours) + } + } + } + + t.Logf("Timezone %s: %.6f hours total (expected %.6f)", tzName, timesheetData.TotalHours, expectedTotalHours) + }) + } +} + +// Helper function for floating point comparison +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} + diff --git a/internal/reports/daterange_test.go b/internal/reports/daterange_test.go index 97678b6..dcb2715 100644 --- a/internal/reports/daterange_test.go +++ b/internal/reports/daterange_test.go @@ -232,6 +232,46 @@ func TestParseDateRange(t *testing.T) { input: "since", wantErr: true, }, + { + name: "custom date range format", + input: "2024-07-01 to 2024-07-31", + wantErr: false, + validate: func(t *testing.T, dr DateRange) { + expectedStart := time.Date(2024, time.July, 1, 0, 0, 0, 0, time.UTC) + expectedEnd := time.Date(2024, time.July, 31, 23, 59, 59, 999999999, time.UTC) + if !dr.Start.Equal(expectedStart) { + t.Errorf("Start = %v, want %v", dr.Start, expectedStart) + } + if !dr.End.Equal(expectedEnd) { + t.Errorf("End = %v, want %v", dr.End, expectedEnd) + } + }, + }, + { + name: "custom date range - same day", + input: "2024-08-15 to 2024-08-15", + wantErr: false, + validate: func(t *testing.T, dr DateRange) { + expectedStart := time.Date(2024, time.August, 15, 0, 0, 0, 0, time.UTC) + expectedEnd := time.Date(2024, time.August, 15, 23, 59, 59, 999999999, time.UTC) + if !dr.Start.Equal(expectedStart) { + t.Errorf("Start = %v, want %v", dr.Start, expectedStart) + } + if !dr.End.Equal(expectedEnd) { + t.Errorf("End = %v, want %v", dr.End, expectedEnd) + } + }, + }, + { + name: "custom date range - invalid order", + input: "2024-08-15 to 2024-08-01", + wantErr: true, + }, + { + name: "custom date range - invalid format", + input: "2024-08-15 through 2024-08-31", + wantErr: true, + }, } for _, tt := range tests { @@ -289,3 +329,165 @@ func TestParseDateRange(t *testing.T) { } } +func TestParseDateRangeDSTTransitions(t *testing.T) { + // Test date range parsing during DST transitions + // Use dates around US DST transitions (2nd Sunday in March, 1st Sunday in November) + + tests := []struct { + name string + input string + currentTime time.Time + wantStart time.Time + wantEnd time.Time + wantErr bool + }{ + { + name: "this week during spring DST transition", + input: "this week", + currentTime: time.Date(2024, time.March, 10, 12, 0, 0, 0, time.UTC), // Sunday of DST transition week + wantStart: time.Date(2024, time.March, 4, 0, 0, 0, 0, time.UTC), // Monday of that week + wantEnd: time.Date(2024, time.March, 10, 23, 59, 59, 999999999, time.UTC), // Sunday 23:59:59 + }, + { + name: "this week during fall DST transition", + input: "this week", + currentTime: time.Date(2024, time.November, 3, 12, 0, 0, 0, time.UTC), // Sunday of DST transition week + wantStart: time.Date(2024, time.October, 28, 0, 0, 0, 0, time.UTC), // Monday of that week + wantEnd: time.Date(2024, time.November, 3, 23, 59, 59, 999999999, time.UTC), // Sunday 23:59:59 + }, + { + name: "last week before spring DST", + input: "last week", + currentTime: time.Date(2024, time.March, 11, 12, 0, 0, 0, time.UTC), // Monday after DST transition + wantStart: time.Date(2024, time.March, 4, 0, 0, 0, 0, time.UTC), // Start of previous week + wantEnd: time.Date(2024, time.March, 10, 23, 59, 59, 999999999, time.UTC), // End of previous week + }, + { + name: "this month with DST transition", + input: "this month", + currentTime: time.Date(2024, time.March, 15, 12, 0, 0, 0, time.UTC), // Mid-March after DST + wantStart: time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2024, time.March, 31, 23, 59, 59, 999999999, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create custom parse function with specific time + testParseFunc := func(dateStr string) (DateRange, error) { + dateStr = strings.TrimSpace(dateStr) + lowerDateStr := strings.ToLower(dateStr) + + switch lowerDateStr { + case "last week": + return getLastWeek(tt.currentTime), nil + case "this week": + return getThisWeek(tt.currentTime), nil + case "last month": + return getLastMonth(tt.currentTime), nil + case "this month": + return getThisMonth(tt.currentTime), nil + } + + return DateRange{}, fmt.Errorf("unsupported test case") + } + + result, err := testParseFunc(tt.input) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !result.Start.Equal(tt.wantStart) { + t.Errorf("Start = %v, want %v", result.Start, tt.wantStart) + } + if !result.End.Equal(tt.wantEnd) { + t.Errorf("End = %v, want %v", result.End, tt.wantEnd) + } + }) + } +} + +func TestParseDateRangeTimezoneEdgeCases(t *testing.T) { + // Test edge cases around timezone boundaries + tests := []struct { + name string + input string + currentTime time.Time + wantStart time.Time + wantEnd time.Time + }{ + { + name: "new years boundary - this week", + input: "this week", + currentTime: time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC), // Wednesday New Year's Day + wantStart: time.Date(2024, time.December, 30, 0, 0, 0, 0, time.UTC), // Monday of that week (prev year) + wantEnd: time.Date(2025, time.January, 5, 23, 59, 59, 999999999, time.UTC), // Sunday of that week (new year) + }, + { + name: "month boundary - this week", + input: "this week", + currentTime: time.Date(2024, time.August, 1, 12, 0, 0, 0, time.UTC), // Thursday Aug 1 + wantStart: time.Date(2024, time.July, 29, 0, 0, 0, 0, time.UTC), // Monday of that week (prev month) + wantEnd: time.Date(2024, time.August, 4, 23, 59, 59, 999999999, time.UTC), // Sunday of that week (current month) + }, + { + name: "leap year february", + input: "february", + currentTime: time.Date(2024, time.August, 15, 10, 30, 0, 0, time.UTC), // 2024 is leap year + wantStart: time.Date(2024, time.February, 1, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2024, time.February, 29, 23, 59, 59, 999999999, time.UTC), // Leap day + }, + { + name: "non-leap year february", + input: "february", + currentTime: time.Date(2023, time.August, 15, 10, 30, 0, 0, time.UTC), // 2023 is not leap year + wantStart: time.Date(2023, time.February, 1, 0, 0, 0, 0, time.UTC), + wantEnd: time.Date(2023, time.February, 28, 23, 59, 59, 999999999, time.UTC), // No leap day + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testParseFunc := func(dateStr string) (DateRange, error) { + dateStr = strings.TrimSpace(dateStr) + lowerDateStr := strings.ToLower(dateStr) + + switch lowerDateStr { + case "this week": + return getThisWeek(tt.currentTime), nil + case "this month": + return getThisMonth(tt.currentTime), nil + } + + // Check for month name patterns + if dateRange, err := parseMonthName(dateStr, tt.currentTime); err == nil { + return dateRange, nil + } + + return DateRange{}, fmt.Errorf("unsupported test case") + } + + result, err := testParseFunc(tt.input) + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !result.Start.Equal(tt.wantStart) { + t.Errorf("Start = %v, want %v", result.Start, tt.wantStart) + } + if !result.End.Equal(tt.wantEnd) { + t.Errorf("End = %v, want %v", result.End, tt.wantEnd) + } + }) + } +} diff --git a/internal/reports/timesheet_test.go b/internal/reports/timesheet_test.go index ed35c86..591b90b 100644 --- a/internal/reports/timesheet_test.go +++ b/internal/reports/timesheet_test.go @@ -119,6 +119,110 @@ func TestGenerateTimesheetData(t *testing.T) { entries: "invalid", wantError: true, }, + { + name: "entries with Pacific timezone (UTC-8)", + entries: []queries.GetTimesheetDataByClientRow{ + { + TimeEntryID: 5, + StartTime: mustParseTime("2025-07-15T08:00:00Z"), // 8 AM UTC = midnight PST + EndTime: sql.NullTime{Time: mustParseTime("2025-07-15T16:00:00Z"), Valid: true}, // 4 PM UTC = 8 AM PST + Description: sql.NullString{String: "Early morning work", Valid: true}, + DurationSeconds: 28800, // 8 hours + }, + }, + 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/Los_Angeles"), + wantEntries: 1, + wantHours: 8.0, + }, + { + name: "entries crossing date boundary in timezone", + entries: []queries.GetTimesheetDataByClientRow{ + { + TimeEntryID: 6, + StartTime: mustParseTime("2025-07-15T23:30:00Z"), // 11:30 PM UTC = 4:30 PM PDT (July 15) + EndTime: sql.NullTime{Time: mustParseTime("2025-07-16T01:30:00Z"), Valid: true}, // 1:30 AM UTC = 6:30 PM PDT (July 15, same day in PDT) + Description: sql.NullString{String: "Late evening work", Valid: true}, + DurationSeconds: 7200, // 2 hours + }, + }, + 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/Los_Angeles"), + wantEntries: 1, + wantHours: 2.0, + }, + { + name: "entries with extreme positive timezone (UTC+14)", + entries: []queries.GetTimesheetDataByClientRow{ + { + TimeEntryID: 7, + StartTime: mustParseTime("2025-07-15T10:00:00Z"), // 10 AM UTC = midnight+1day in UTC+14 + EndTime: sql.NullTime{Time: mustParseTime("2025-07-15T12:00:00Z"), Valid: true}, // 12 PM UTC = 2 AM+1day in UTC+14 + Description: sql.NullString{String: "Future timezone work", Valid: true}, + DurationSeconds: 7200, // 2 hours + }, + }, + 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.FixedZone("UTC+14", 14*3600), + wantEntries: 1, + wantHours: 2.0, + }, + { + name: "entries with extreme negative timezone (UTC-12)", + entries: []queries.GetTimesheetDataByClientRow{ + { + TimeEntryID: 8, + StartTime: mustParseTime("2025-07-15T12:00:00Z"), // 12 PM UTC = midnight in UTC-12 + EndTime: sql.NullTime{Time: mustParseTime("2025-07-15T15:00:00Z"), Valid: true}, // 3 PM UTC = 3 AM in UTC-12 + Description: sql.NullString{String: "Early timezone work", Valid: true}, + DurationSeconds: 10800, // 3 hours + }, + }, + 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.FixedZone("UTC-12", -12*3600), + wantEntries: 1, + wantHours: 3.0, + }, } for _, tt := range tests { @@ -250,6 +354,102 @@ func TestConvertToTimesheetEntries(t *testing.T) { timezone: time.UTC, want: []TimesheetEntry{}, // Should be empty }, + { + name: "entry crossing midnight boundary in Pacific timezone", + entries: []timesheetEntryData{ + { + TimeEntryID: 4, + StartTime: mustParseTime("2025-07-15T23:00:00Z"), // 11 PM UTC = 4 PM PDT (July 15) + EndTime: sql.NullTime{Time: mustParseTime("2025-07-16T03:00:00Z"), Valid: true}, // 3 AM UTC = 8 PM PDT (July 15) + Description: sql.NullString{String: "Evening work", Valid: true}, + DurationSeconds: 14400, // 4 hours + }, + }, + timezone: mustLoadLocation("America/Los_Angeles"), + want: []TimesheetEntry{ + { + Date: "2025-07-15", // Should be July 15 in PDT despite UTC crossing midnight + StartTime: "16:00", // 4:00 PM PDT + EndTime: "20:00", // 8:00 PM PDT + Duration: "4:00", + Hours: 4.0, + ProjectName: "", + Description: "Evening work", + }, + }, + }, + { + name: "entry in extreme timezone (UTC+14)", + entries: []timesheetEntryData{ + { + TimeEntryID: 5, + StartTime: mustParseTime("2025-07-15T10:00:00Z"), // 10 AM UTC = midnight next day in UTC+14 + EndTime: sql.NullTime{Time: mustParseTime("2025-07-15T13:00:00Z"), Valid: true}, // 1 PM UTC = 3 AM next day in UTC+14 + Description: sql.NullString{String: "Future timezone work", Valid: true}, + DurationSeconds: 10800, // 3 hours + }, + }, + timezone: time.FixedZone("UTC+14", 14*3600), + want: []TimesheetEntry{ + { + Date: "2025-07-16", // Next day in UTC+14 + StartTime: "00:00", // Midnight + EndTime: "03:00", // 3 AM + Duration: "3:00", + Hours: 3.0, + ProjectName: "", + Description: "Future timezone work", + }, + }, + }, + { + name: "entry spanning DST transition (spring forward)", + entries: []timesheetEntryData{ + { + TimeEntryID: 6, + StartTime: mustParseTime("2024-03-10T06:30:00Z"), // 1:30 AM EST, before DST + EndTime: sql.NullTime{Time: mustParseTime("2024-03-10T10:30:00Z"), Valid: true}, // 5:30 AM EST/6:30 AM EDT after DST + Description: sql.NullString{String: "DST transition work", Valid: true}, + DurationSeconds: 14400, // 4 hours + }, + }, + timezone: mustLoadLocation("America/New_York"), + want: []TimesheetEntry{ + { + Date: "2024-03-10", + StartTime: "01:30", // EST time before spring forward + EndTime: "06:30", // EDT time after spring forward (skips 2-3 AM) + Duration: "4:00", // Duration is still 4 hours + Hours: 4.0, + ProjectName: "", + Description: "DST transition work", + }, + }, + }, + { + name: "rounding to nearest minute", + entries: []timesheetEntryData{ + { + TimeEntryID: 7, + StartTime: mustParseTime("2025-07-10T14:00:00Z"), + EndTime: sql.NullTime{Time: mustParseTime("2025-07-10T15:01:29Z"), Valid: true}, // 1 hour 1 minute 29 seconds + Description: sql.NullString{String: "Rounding test", Valid: true}, + DurationSeconds: 3689, // 1:01:29 + }, + }, + timezone: time.UTC, + want: []TimesheetEntry{ + { + Date: "2025-07-10", + StartTime: "14:00", + EndTime: "15:01", + Duration: "1:01", // Rounds down to 61 minutes + Hours: 1.0167, // (3689+30)/60 = 3719 seconds = 61.9833 minutes = 1.0331 hours rounded to nearest minute + ProjectName: "", + Description: "Rounding test", + }, + }, + }, } for _, tt := range tests { diff --git a/internal/tui/modal_test.go b/internal/tui/modal_test.go new file mode 100644 index 0000000..64957ce --- /dev/null +++ b/internal/tui/modal_test.go @@ -0,0 +1,470 @@ +package tui + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.tjp.lol/punchcard/internal/queries" + _ "modernc.org/sqlite" +) + +func TestValidateAndParseEntryForm(t *testing.T) { + // Test timezone handling in modal forms + tests := []struct { + name string + setupData func(*queries.Queries) (entryID int64, err error) + formValues []string // Values for each form field in order + expectError bool + expectedStart string // Expected UTC format + expectedEnd string // Expected UTC format + }{ + { + name: "local time input converted to UTC storage", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Test work", Valid: true}, + ClientID: client.ID, + }) + if err != nil { + return 0, err + } + + // Stop the entry so we can edit it + _, err = q.StopTimeEntry(context.Background()) + return entry.ID, err + }, + formValues: []string{ + "2024-08-22 14:30:00", // Start time (local) + "2024-08-22 16:45:00", // End time (local) + "TestClient", // Client + "", // Project + "Updated description", // Description + "125.50", // Rate + }, + expectError: false, + expectedStart: "2024-08-22 14:30:00", // Should be converted to UTC format + expectedEnd: "2024-08-22 16:45:00", // Should be converted to UTC format + }, + { + name: "invalid time format should error", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Test work", Valid: true}, + ClientID: client.ID, + }) + if err != nil { + return 0, err + } + + _, err = q.StopTimeEntry(context.Background()) + return entry.ID, err + }, + formValues: []string{ + "invalid-time-format", // Invalid start time + "2024-08-22 16:45:00", // Valid end time + "TestClient", // Client + "", // Project + "Updated description", // Description + "125.50", // Rate + }, + expectError: true, + }, + { + name: "empty end time for completed entry should error", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + entry, err := q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + Description: sql.NullString{String: "Test work", Valid: true}, + ClientID: client.ID, + }) + if err != nil { + return 0, err + } + + _, err = q.StopTimeEntry(context.Background()) + return entry.ID, err + }, + formValues: []string{ + "2024-08-22 14:30:00", // Start time + "", // Empty end time (trying to re-open completed entry) + "TestClient", // Client + "", // Project + "Updated description", // Description + "125.50", // Rate + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, _, cleanup := setupTestDB(t) + defer cleanup() + + // Setup test data + entryID, err := tt.setupData(q) + if err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Create a modal with entry form + modal := &ModalBoxModel{ + Type: ModalTypeEntry, + editedID: entryID, + form: NewEntryEditorForm(), + } + + // Set form field values + for i, value := range tt.formValues { + if i < len(modal.form.fields) { + modal.form.fields[i].SetValue(value) + } + } + + // Create a minimal AppModel for testing + appModel := AppModel{ + queries: q, + } + + // Test validateAndParseEntryForm + params, hasErrors := modal.validateAndParseEntryForm(appModel) + + if tt.expectError { + if !hasErrors { + t.Errorf("Expected validation errors but got none") + } + return + } + + if hasErrors { + // Check which fields have errors for debugging + for i, field := range modal.form.fields { + if field.Err != nil { + t.Logf("Field %d error: %v", i, field.Err) + } + } + t.Errorf("Unexpected validation errors") + return + } + + // Verify the parameters were parsed correctly + if params.EntryID != entryID { + t.Errorf("Expected entry ID %d, got %d", entryID, params.EntryID) + } + + // Verify start time conversion + if tt.expectedStart != "" { + // Parse the expected local time and convert to UTC for comparison + expectedLocal, err := time.ParseInLocation(time.DateTime, tt.expectedStart, time.Local) + if err != nil { + t.Fatalf("Failed to parse expected start time: %v", err) + } + expectedUTC := expectedLocal.UTC().Format(time.DateTime) + + if params.StartTime != expectedUTC { + t.Errorf("Expected start time %s (UTC), got %s", expectedUTC, params.StartTime) + } + } + + // Verify end time conversion + if tt.expectedEnd != "" { + expectedLocal, err := time.ParseInLocation(time.DateTime, tt.expectedEnd, time.Local) + if err != nil { + t.Fatalf("Failed to parse expected end time: %v", err) + } + expectedUTC := expectedLocal.UTC().Format(time.DateTime) + + if params.EndTime != expectedUTC { + t.Errorf("Expected end time %s (UTC), got %s", expectedUTC, params.EndTime) + } + } + + // Verify rate was parsed correctly + if tt.formValues[5] != "" { + expectedRate := 12550 // 125.50 * 100 (converted to cents) + if !params.HourlyRate.Valid || params.HourlyRate.Int64 != int64(expectedRate) { + t.Errorf("Expected hourly rate %d cents, got %v", expectedRate, params.HourlyRate) + } + } + }) + } +} + +func TestFormTimezoneValidation(t *testing.T) { + // Test timezone-related validation in form fields + tests := []struct { + name string + fieldType string + value string + expectError bool + }{ + { + name: "valid timestamp format", + fieldType: "timestamp", + value: "2024-08-22 14:30:00", + expectError: false, + }, + { + name: "invalid timestamp format - missing seconds", + fieldType: "timestamp", + value: "2024-08-22 14:30", + expectError: true, + }, + { + name: "invalid timestamp format - wrong separator", + fieldType: "timestamp", + value: "2024/08/22 14:30:00", + expectError: true, + }, + { + name: "empty optional timestamp", + fieldType: "optional_timestamp", + value: "", + expectError: false, + }, + { + name: "valid optional timestamp", + fieldType: "optional_timestamp", + value: "2024-08-22 16:45:00", + expectError: false, + }, + { + name: "invalid optional timestamp", + fieldType: "optional_timestamp", + value: "not-a-timestamp", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var field FormField + + switch tt.fieldType { + case "timestamp": + field = newTimestampField("Test Field") + case "optional_timestamp": + field = newOptionalTimestampField("Test Optional Field") + default: + t.Fatalf("Unknown field type: %s", tt.fieldType) + } + + // Set the value and validate + field.SetValue(tt.value) + err := field.Validate(tt.value) + + if tt.expectError { + if err == nil { + t.Errorf("Expected validation error for value %q, but got none", tt.value) + } + } else { + if err != nil { + t.Errorf("Expected no validation error for value %q, but got: %v", tt.value, err) + } + } + }) + } +} + +func TestModalEntryEditingTimezone(t *testing.T) { + // Test that modal editing maintains timezone consistency + tests := []struct { + name string + setupData func(*queries.Queries) (int64, error) + }{ + { + name: "editing preserves UTC storage", + setupData: func(q *queries.Queries) (int64, error) { + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return 0, err + } + + // Create entry with known UTC time + _, err = q.DBTX().(*sql.DB).Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'Original work') + `, "2024-08-22 18:00:00", "2024-08-22 19:30:00", client.ID) + if err != nil { + return 0, err + } + + // Get the entry ID + var entryID int64 + err = q.DBTX().(*sql.DB).QueryRow("SELECT id FROM time_entry ORDER BY id DESC LIMIT 1").Scan(&entryID) + return entryID, err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, db, cleanup := setupTestDB(t) + defer cleanup() + + entryID, err := tt.setupData(q) + if err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Get the original entry to verify UTC storage + var originalStart, originalEnd string + err = db.QueryRow("SELECT start_time, end_time FROM time_entry WHERE id = ?", entryID).Scan(&originalStart, &originalEnd) + if err != nil { + t.Fatalf("Failed to get original entry: %v", err) + } + + // Parse and verify original times are in a valid time format + // SQLite might return different formats, so try multiple + formats := []string{ + time.DateTime, // "2006-01-02 15:04:05" + time.RFC3339, // "2006-01-02T15:04:05Z" + "2006-01-02T15:04:05", // ISO format without Z + } + + startParsed := false + for _, format := range formats { + if _, err := time.Parse(format, originalStart); err == nil { + startParsed = true + break + } + } + if !startParsed { + t.Errorf("Original start time not in a recognized format: %s", originalStart) + } + + endParsed := false + for _, format := range formats { + if _, err := time.Parse(format, originalEnd); err == nil { + endParsed = true + break + } + } + if !endParsed { + t.Errorf("Original end time not in a recognized format: %s", originalEnd) + } + + // The key insight is that the modal should accept local time input + // but store UTC time in the database, maintaining consistency + t.Logf("Original entry stored with start=%s, end=%s (should be UTC)", originalStart, originalEnd) + }) + } +} + +func TestTimezoneConsistencyAcrossEditing(t *testing.T) { + // Integration test: verify that editing a time entry maintains timezone consistency + // between display (local) and storage (UTC) + + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ConsistencyTestClient", + }) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Create an entry with a known UTC time + utcStart := "2024-08-22 20:00:00" // 8 PM UTC + utcEnd := "2024-08-22 22:30:00" // 10:30 PM UTC + + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES (?, ?, ?, 'Consistency test') + `, utcStart, utcEnd, client.ID) + if err != nil { + t.Fatalf("Failed to create entry: %v", err) + } + + // Get the entry ID + var entryID int64 + err = db.QueryRow("SELECT id FROM time_entry ORDER BY id DESC LIMIT 1").Scan(&entryID) + if err != nil { + t.Fatalf("Failed to get entry ID: %v", err) + } + + // Create modal for editing + modal := &ModalBoxModel{ + Type: ModalTypeEntry, + editedID: entryID, + form: NewEntryEditorForm(), + } + + // Simulate what the UI would do: convert UTC to local for display + startUTC, _ := time.Parse(time.DateTime, utcStart) + endUTC, _ := time.Parse(time.DateTime, utcEnd) + + startLocal := startUTC.Local().Format(time.DateTime) + endLocal := endUTC.Local().Format(time.DateTime) + + // Set form values as if user is editing (using local time display) + formValues := []string{ + startLocal, // Start time in local format + endLocal, // End time in local format + "ConsistencyTestClient", // Client + "", // Project + "Updated description", // Description + "100.00", // Rate + } + + for i, value := range formValues { + if i < len(modal.form.fields) { + modal.form.fields[i].SetValue(value) + } + } + + // Create minimal AppModel + appModel := AppModel{ + queries: q, + } + + // Validate and parse the form (should convert back to UTC) + params, hasErrors := modal.validateAndParseEntryForm(appModel) + if hasErrors { + for i, field := range modal.form.fields { + if field.Err != nil { + t.Logf("Field %d error: %v", i, field.Err) + } + } + t.Fatalf("Unexpected validation errors") + } + + // The parsed params should contain UTC times again + if params.StartTime != utcStart { + t.Errorf("Expected start time to round-trip to UTC: expected %s, got %s", utcStart, params.StartTime) + } + + if params.EndTime != utcEnd { + t.Errorf("Expected end time to round-trip to UTC: expected %s, got %s", utcEnd, params.EndTime) + } + + t.Logf("Timezone consistency verified: Local display (%s->%s) converts back to UTC storage (%s->%s)", + startLocal, endLocal, params.StartTime, params.EndTime) +} + diff --git a/internal/tui/shared_test.go b/internal/tui/shared_test.go new file mode 100644 index 0000000..1df3eb9 --- /dev/null +++ b/internal/tui/shared_test.go @@ -0,0 +1,403 @@ +package tui + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.tjp.lol/punchcard/internal/queries" + _ "modernc.org/sqlite" +) + +// setupTestDB creates an in-memory SQLite database for testing +func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open in-memory sqlite db: %v", err) + } + + // Simple but complete schema setup for testing + schema := ` + CREATE TABLE time_entry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_time DATETIME NOT NULL, + end_time DATETIME, + description TEXT, + client_id INTEGER NOT NULL, + project_id INTEGER, + billable_rate INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE client ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + email TEXT, + billable_rate INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE project ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + client_id INTEGER NOT NULL, + billable_rate INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES client (id) + ); + CREATE TABLE contractor ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + label TEXT, + email TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + _, err = db.Exec(schema) + if err != nil { + t.Fatalf("Failed to create test schema: %v", err) + } + + q := queries.New(db) + + cleanup := func() { + if err := db.Close(); err != nil { + t.Logf("error closing database: %v", err) + } + } + + return q, db, cleanup +} + +func TestMostRecentMonday(t *testing.T) { + tests := []struct { + name string + fromTime time.Time + wantDay time.Weekday + }{ + { + name: "from wednesday gets previous monday", + fromTime: time.Date(2024, time.August, 21, 15, 30, 0, 0, time.UTC), // Wednesday + wantDay: time.Monday, + }, + { + name: "from monday gets same monday", + fromTime: time.Date(2024, time.August, 19, 10, 0, 0, 0, time.UTC), // Monday + wantDay: time.Monday, + }, + { + name: "from sunday gets next monday (current week)", + fromTime: time.Date(2024, time.August, 18, 20, 0, 0, 0, time.UTC), // Sunday + wantDay: time.Monday, + }, + { + name: "timezone boundary - UTC time vs local time", + fromTime: time.Date(2024, time.August, 19, 2, 0, 0, 0, time.UTC), // Monday 2 AM UTC + wantDay: time.Monday, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mostRecentMonday(tt.fromTime) + + if result.Weekday() != tt.wantDay { + t.Errorf("mostRecentMonday(%v) weekday = %v, want %v", tt.fromTime, result.Weekday(), tt.wantDay) + } + + // The function always converts to local time, so compare appropriately + localFromTime := tt.fromTime.Local() + + // For Sunday, the function returns Monday of the same week (which is after Sunday) + // For other days, it should return a Monday that's not after the input day + if localFromTime.Weekday() != time.Sunday { + if result.After(localFromTime) { + t.Errorf("mostRecentMonday(%v) = %v, should not be after local input time %v", tt.fromTime, result, localFromTime) + } + } + + // Verify result is within reasonable range (allowing Monday after Sunday) + daysDiff := localFromTime.Sub(result).Hours() / 24 + if daysDiff > 7 || daysDiff < -1.5 { // Allow for Monday after Sunday + t.Errorf("mostRecentMonday(%v) = %v, should be within reasonable range of local input %v, got %v days", tt.fromTime, result, localFromTime, daysDiff) + } + }) + } +} + +func TestMostRecentMondayTimezoneConsistency(t *testing.T) { + // Test that mostRecentMonday works consistently across timezones + utcTime := time.Date(2024, time.August, 20, 1, 0, 0, 0, time.UTC) // Tuesday 1 AM UTC + + tests := []struct { + name string + timezone string + offset int // hours from UTC + }{ + {"Pacific", "America/Los_Angeles", -8}, + {"Eastern", "America/New_York", -5}, + {"UTC", "UTC", 0}, + {"Tokyo", "Asia/Tokyo", 9}, + {"Sydney", "Australia/Sydney", 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc, err := time.LoadLocation(tt.timezone) + if err != nil { + t.Skipf("Timezone %s not available: %v", tt.timezone, err) + } + + localTime := utcTime.In(loc) + monday := mostRecentMonday(localTime) + + if monday.Weekday() != time.Monday { + t.Errorf("Expected Monday, got %v for timezone %s", monday.Weekday(), tt.timezone) + } + + // The function always returns local time, not the input timezone + if monday.Location() != time.Local { + t.Errorf("Expected Local timezone, got %v", monday.Location()) + } + }) + } +} + +func TestDateOnly(t *testing.T) { + tests := []struct { + name string + input time.Time + wantHour int + wantMin int + wantSec int + wantLoc *time.Location + }{ + { + name: "UTC time to date only", + input: time.Date(2024, time.August, 21, 15, 30, 45, 123456789, time.UTC), + wantHour: 0, + wantMin: 0, + wantSec: 0, + wantLoc: time.UTC, + }, + { + name: "Local time preserves timezone", + input: time.Date(2024, time.August, 21, 23, 59, 59, 0, time.Local), + wantHour: 0, + wantMin: 0, + wantSec: 0, + wantLoc: time.Local, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := dateOnly(tt.input) + + if result.Hour() != tt.wantHour { + t.Errorf("dateOnly(%v).Hour() = %d, want %d", tt.input, result.Hour(), tt.wantHour) + } + if result.Minute() != tt.wantMin { + t.Errorf("dateOnly(%v).Minute() = %d, want %d", tt.input, result.Minute(), tt.wantMin) + } + if result.Second() != tt.wantSec { + t.Errorf("dateOnly(%v).Second() = %d, want %d", tt.input, result.Second(), tt.wantSec) + } + if result.Location() != tt.wantLoc { + t.Errorf("dateOnly(%v).Location() = %v, want %v", tt.input, result.Location(), tt.wantLoc) + } + + // Verify same date + if result.Year() != tt.input.Year() || result.Month() != tt.input.Month() || result.Day() != tt.input.Day() { + t.Errorf("dateOnly(%v) changed date: got %v", tt.input, result) + } + }) + } +} + +func TestGetTimerInfoTimezoneHandling(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries, *sql.DB) error + expectActive bool + expectDuration bool // whether duration should be > 0 + }{ + { + name: "active timer duration calculation", + setupData: func(q *queries.Queries, db *sql.DB) error { + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return err + } + + // Create active time entry (start_time in UTC) + _, err = db.Exec(` + INSERT INTO time_entry (start_time, client_id, description) + VALUES (datetime('now', 'utc', '-1 hour'), ?, 'Test work') + `, client.ID) + return err + }, + expectActive: true, + expectDuration: true, + }, + { + name: "no active timer falls back to most recent", + setupData: func(q *queries.Queries, db *sql.DB) error { + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + if err != nil { + return err + } + + // Create completed time entry + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) + VALUES ( + datetime('now', 'utc', '-2 hours'), + datetime('now', 'utc', '-1 hour'), + ?, 'Completed work' + ) + `, client.ID) + return err + }, + expectActive: false, + expectDuration: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Setup test data + if err := tt.setupData(q, db); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Test getTimerInfo + info, err := getTimerInfo(context.Background(), q) + if err != nil { + t.Fatalf("getTimerInfo failed: %v", err) + } + + if info.IsActive != tt.expectActive { + t.Errorf("Expected IsActive=%v, got %v", tt.expectActive, info.IsActive) + } + + if tt.expectDuration { + if info.Duration <= 0 { + t.Errorf("Expected positive duration, got %v", info.Duration) + } + } + + // Verify StartTime timezone handling + if info.StartTime.IsZero() { + t.Error("Expected non-zero StartTime") + } + + // For active timers, verify the duration calculation makes sense + if info.IsActive && tt.expectDuration { + // The duration should be reasonable (we inserted 1 hour ago) + expectedMin := 55 * time.Minute // Allow some margin for test execution time + expectedMax := 65 * time.Minute + + if info.Duration < expectedMin || info.Duration > expectedMax { + t.Errorf("Expected duration between %v and %v, got %v", expectedMin, expectedMax, info.Duration) + } + } + }) + } +} + +func TestGetAppDataTimezoneFiltering(t *testing.T) { + // Test that getAppData correctly filters "Today" and "Week" totals using local timezone + tests := []struct { + name string + setupData func(*queries.Queries, *sql.DB) error + }{ + { + name: "today and week filtering uses local timezone", + setupData: func(q *queries.Queries, db *sql.DB) error { + // Create client + client, err := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "FilterTestClient", + }) + if err != nil { + return err + } + + now := time.Now() + today := now.Format("2006-01-02") + yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") + + // Insert entries for today and yesterday in UTC + _, err = db.Exec(` + INSERT INTO time_entry (start_time, end_time, client_id, description) VALUES + (?, ?, ?, 'Today work'), + (?, ?, ?, 'Yesterday work') + `, + today+" 10:00:00", today+" 11:00:00", client.ID, + yesterday+" 10:00:00", yesterday+" 11:00:00", client.ID, + ) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, db, cleanup := setupTestDB(t) + defer cleanup() + + // Create a default contractor first + _, err := q.CreateContractor(context.Background(), queries.CreateContractorParams{ + Name: "Default Contractor", + Label: "Testing", + Email: "test@example.com", + }) + if err != nil { + t.Fatalf("Failed to create contractor: %v", err) + } + + if err := tt.setupData(q, db); err != nil { + t.Fatalf("Failed to setup test data: %v", err) + } + + // Test with empty filter to get all data + filter := HistoryFilter{ + StartDate: time.Now().AddDate(0, 0, -7), // Last 7 days + } + + _, info, stats, _, _, _, err := getAppData(context.Background(), q, filter) + if err != nil { + t.Fatalf("getAppData failed: %v", err) + } + + // Verify that we got some time stats + // The exact values depend on the current time and setup, so we just verify they're reasonable + if stats.TodayTotal < 0 { + t.Errorf("Expected non-negative today total, got %v", stats.TodayTotal) + } + + if stats.WeekTotal < stats.TodayTotal { + t.Errorf("Expected week total >= today total, got week=%v, today=%v", stats.WeekTotal, stats.TodayTotal) + } + + // If there's an active timer, verify it contributes to both today and week + if info.IsActive { + expectedContribution := info.Duration + // The stats should include active time, but exact verification is complex + // due to timing differences, so we just verify structure + if stats.TodayTotal == 0 && expectedContribution > 0 { + t.Log("Note: Active timer contribution might not be reflected in test due to timing") + } + } + }) + } +} + -- cgit v1.2.3