summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/modal_test.go470
-rw-r--r--internal/tui/shared_test.go403
2 files changed, 873 insertions, 0 deletions
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")
+ }
+ }
+ })
+ }
+}
+