summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/commands/in_test.go187
-rw-r--r--internal/commands/out_test.go170
-rw-r--r--internal/database/queries_test.go441
-rw-r--r--internal/integration/timezone_test.go503
-rw-r--r--internal/reports/daterange_test.go202
-rw-r--r--internal/reports/timesheet_test.go200
-rw-r--r--internal/tui/modal_test.go470
-rw-r--r--internal/tui/shared_test.go403
8 files changed, 2576 insertions, 0 deletions
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")
+ }
+ }
+ })
+ }
+}
+