From c8e424378f0d277f832add84a8ce0f674f24245b Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 11 Jan 2026 16:25:41 -0800 Subject: [PATCH 1/2] Enforce UTC timestamps and add migration system All time-related database fields are now stored in UTC, and a migration infrastructure has been introduced to convert existing timestamps to UTC. Time formatting functions now display times in the user's configured timezone. This ensures consistency across time zones and prepares the codebase for future database migrations. --- cmd/history/log.go | 4 +- cmd/history/stats.go | 4 +- internal/settings/global_config.go | 32 ++++ internal/storage/db.go | 270 ++++++++++++++++++++++++++++- 4 files changed, 297 insertions(+), 13 deletions(-) diff --git a/cmd/history/log.go b/cmd/history/log.go index 9fd5b2b..f38f458 100644 --- a/cmd/history/log.go +++ b/cmd/history/log.go @@ -47,7 +47,7 @@ func LogCmd() *cobra.Command { } entries, err = db.GetEntriesByMilestone(projectName, logMilestone) } else if logToday { - start := time.Now().Truncate(24 * time.Hour) + start := time.Now().Truncate(24 * time.Hour).UTC() end := start.Add(24 * time.Hour) entries, err = db.GetEntriesByDateRange(start, end) } else if logWeek { @@ -57,7 +57,7 @@ func LogCmd() *cobra.Command { weekday = 7 // sunday } - start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour).UTC() end := start.AddDate(0, 0, 7) entries, err = db.GetEntriesByDateRange(start, end) } else if logProject != "" { diff --git a/cmd/history/stats.go b/cmd/history/stats.go index 690c072..7a23907 100644 --- a/cmd/history/stats.go +++ b/cmd/history/stats.go @@ -39,7 +39,7 @@ func StatsCmd() *cobra.Command { if statsToday { now := time.Now() - start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UTC() end = start.Add(24 * time.Hour) periodName = "Today" } else if statsWeek { @@ -49,7 +49,7 @@ func StatsCmd() *cobra.Command { weekday = 7 } - start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -weekday+1) + start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UTC().AddDate(0, 0, -weekday+1) end = start.AddDate(0, 0, 7) periodName = "This Week" } else { diff --git a/internal/settings/global_config.go b/internal/settings/global_config.go index 8a8fc6b..07da812 100644 --- a/internal/settings/global_config.go +++ b/internal/settings/global_config.go @@ -92,7 +92,29 @@ func (gc *GlobalConfig) Save() error { return nil } +// getDisplayTimezone returns the user's configured timezone or local timezone as fallback +func getDisplayTimezone() *time.Location { + cfg, err := LoadGlobalConfig() + if err != nil || cfg.Timezone == "" { + return time.Local + } + + loc, err := time.LoadLocation(cfg.Timezone) + if err != nil { + return time.Local + } + + return loc +} + +// toDisplayTime converts a UTC time to the user's display timezone +func toDisplayTime(t time.Time) time.Time { + return t.In(getDisplayTimezone()) +} + func FormatTime(t time.Time) string { + t = toDisplayTime(t) + cfg, err := LoadGlobalConfig() if err != nil || cfg.TimeFormat == "" || cfg.TimeFormat == "Keep current" { return t.Format("3:04 PM") @@ -106,6 +128,8 @@ func FormatTime(t time.Time) string { } func FormatTimePadded(t time.Time) string { + t = toDisplayTime(t) + cfg, err := LoadGlobalConfig() if err != nil || cfg.TimeFormat == "" || cfg.TimeFormat == "Keep current" { return t.Format("03:04 PM") @@ -119,6 +143,8 @@ func FormatTimePadded(t time.Time) string { } func FormatDate(t time.Time) string { + t = toDisplayTime(t) + cfg, err := LoadGlobalConfig() if err != nil || cfg.DateFormat == "" || cfg.DateFormat == "Keep current" { return t.Format("01/02/2006") @@ -137,6 +163,8 @@ func FormatDate(t time.Time) string { } func FormatDateDashed(t time.Time) string { + t = toDisplayTime(t) + cfg, err := LoadGlobalConfig() if err != nil || cfg.DateFormat == "" || cfg.DateFormat == "Keep current" { return t.Format("01-02-2006") @@ -163,10 +191,14 @@ func FormatDateTimeDashed(t time.Time) string { } func FormatDateLong(t time.Time) string { + t = toDisplayTime(t) + return t.Format("Mon, Jan 2, 2006") } func FormatDateTimeLong(t time.Time) string { + t = toDisplayTime(t) + cfg, err := LoadGlobalConfig() if err != nil || cfg.TimeFormat == "" || cfg.TimeFormat == "Keep current" { return t.Format("Jan 2, 2006 at 3:04 PM") diff --git a/internal/storage/db.go b/internal/storage/db.go index 4e0f9c4..f0fc112 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -15,6 +15,12 @@ type Database struct { db *sql.DB } +// Migration keys +// ! I'm adding this system so that future database migrations will be easier - Dylan +const ( + Migration001_UTCTimestamps = "001_utc_timestamps" +) + func Initialize() (*Database, error) { homeDir, err := os.UserHomeDir() @@ -88,7 +94,25 @@ func Initialize() (*Database, error) { return nil, fmt.Errorf("failed to create index: %w", err) } - return &Database{db: db}, nil + // settings table for tracking migrations and other metadata + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL + ) + `) + if err != nil { + return nil, fmt.Errorf("failed to create settings table: %w", err) + } + + database := &Database{db: db} + + if err := database.runMigrations(); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + return database, nil } func isColumnExistsError(err error) bool { @@ -114,7 +138,7 @@ func (d *Database) CreateEntry(projectName, description string, hourlyRate *floa result, err := d.db.Exec( "INSERT INTO time_entries (project_name, start_time, description, hourly_rate, milestone_name) VALUES (?, ?, ?, ?, ?)", projectName, - time.Now(), + time.Now().UTC(), description, rate, milestone, @@ -144,11 +168,14 @@ func (d *Database) CreateManualEntry(projectName, description string, startTime, milestone = sql.NullString{String: *milestoneName, Valid: true} } + startTimeUTC := startTime.UTC() + endTimeUTC := endTime.UTC() + result, err := d.db.Exec( "INSERT INTO time_entries (project_name, start_time, end_time, description, hourly_rate, milestone_name) VALUES (?, ?, ?, ?, ?, ?)", projectName, - startTime, - endTime, + startTimeUTC, + endTimeUTC, description, rate, milestone, @@ -244,7 +271,7 @@ func (d *Database) GetLastStoppedEntry() (*TimeEntry, error) { func (d *Database) StopEntry(id int64) error { _, err := d.db.Exec( "UPDATE time_entries SET end_time = ? WHERE id = ?", - time.Now(), + time.Now().UTC(), id, ) @@ -526,9 +553,11 @@ func (d *Database) GetCompletedEntriesByProject(projectName string) ([]*TimeEntr } func (d *Database) UpdateTimeEntry(id int64, entry *TimeEntry) error { + startTimeUTC := entry.StartTime.UTC() + var endTime sql.NullTime if entry.EndTime != nil { - endTime = sql.NullTime{Time: *entry.EndTime, Valid: true} + endTime = sql.NullTime{Time: entry.EndTime.UTC(), Valid: true} } var hourlyRate sql.NullFloat64 @@ -545,7 +574,7 @@ func (d *Database) UpdateTimeEntry(id int64, entry *TimeEntry) error { UPDATE time_entries SET project_name = ?, start_time = ?, end_time = ?, description = ?, hourly_rate = ?, milestone_name = ? WHERE id = ? - `, entry.ProjectName, entry.StartTime, endTime, entry.Description, hourlyRate, milestoneName, id) + `, entry.ProjectName, startTimeUTC, endTime, entry.Description, hourlyRate, milestoneName, id) if err != nil { return fmt.Errorf("failed to update entry: %w", err) @@ -567,7 +596,7 @@ func (d *Database) CreateMilestone(projectName, name string) (*Milestone, error) "INSERT INTO milestones (project_name, name, start_time) VALUES (?, ?, ?)", projectName, name, - time.Now(), + time.Now().UTC(), ) if err != nil { @@ -719,7 +748,7 @@ func (d *Database) GetAllMilestones() ([]*Milestone, error) { func (d *Database) FinishMilestone(id int64) error { _, err := d.db.Exec( "UPDATE milestones SET end_time = ? WHERE id = ?", - time.Now(), + time.Now().UTC(), id, ) @@ -774,4 +803,227 @@ func (d *Database) GetEntriesByMilestone(projectName, milestoneName string) ([]* func (d *Database) Close() error { return d.db.Close() +} + +// Migration infrastructure + +// hasMigrationRun checks if a specific migration has already been executed +func (d *Database) hasMigrationRun(migrationKey string) (bool, error) { + var value string + err := d.db.QueryRow("SELECT value FROM settings WHERE key = ?", migrationKey).Scan(&value) + + if err == sql.ErrNoRows { + return false, nil + } + + if err != nil { + return false, fmt.Errorf("failed to check migration status: %w", err) + } + + return value == "completed", nil +} + +// markMigrationComplete marks a migration as completed +func (d *Database) markMigrationComplete(migrationKey string) error { + _, err := d.db.Exec( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", + migrationKey, + "completed", + time.Now().UTC(), + ) + + if err != nil { + return fmt.Errorf("failed to mark migration complete: %w", err) + } + + return nil +} + +// runMigrations executes all pending migrations +func (d *Database) runMigrations() error { + // Migration 1: Convert all timestamps to UTC + if err := d.migrateTimestampsToUTC(); err != nil { + return fmt.Errorf("timestamp UTC migration failed: %w", err) + } + + return nil +} + +// migrateTimestampsToUTC converts all existing timestamps from local timezone to UTC +func (d *Database) migrateTimestampsToUTC() error { + // Check if already migrated + completed, err := d.hasMigrationRun(Migration001_UTCTimestamps) + if err != nil { + return err + } + + if completed { + // Migration already completed, skip + return nil + } + + // Start transaction for safety - if anything fails, nothing changes + tx, err := d.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + // Ensure we rollback on any error + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Migrate time_entries table + if err = d.migrateTimeEntriesTableToUTC(tx); err != nil { + return fmt.Errorf("failed to migrate time_entries: %w", err) + } + + // Migrate milestones table + if err = d.migrateMilestonesTableToUTC(tx); err != nil { + return fmt.Errorf("failed to migrate milestones: %w", err) + } + + // Mark migration as complete (within the transaction) + _, err = tx.Exec( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", + Migration001_UTCTimestamps, + "completed", + time.Now().UTC(), + ) + if err != nil { + return fmt.Errorf("failed to mark migration complete: %w", err) + } + + // Commit transaction - only now do changes take effect + if err = tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration transaction: %w", err) + } + + return nil +} + +// migrateTimeEntriesTableToUTC converts all time_entries timestamps to UTC +func (d *Database) migrateTimeEntriesTableToUTC(tx *sql.Tx) error { + // Get all entries + rows, err := tx.Query("SELECT id, start_time, end_time FROM time_entries") + if err != nil { + return fmt.Errorf("failed to query time_entries: %w", err) + } + defer rows.Close() + + type entryUpdate struct { + id int64 + startTime time.Time + endTime sql.NullTime + } + + var updates []entryUpdate + + for rows.Next() { + var entry entryUpdate + + if err := rows.Scan(&entry.id, &entry.startTime, &entry.endTime); err != nil { + return fmt.Errorf("failed to scan entry: %w", err) + } + + // Check if start_time needs conversion (not already UTC) + needsUpdate := false + + if entry.startTime.Location() != time.UTC { + entry.startTime = entry.startTime.UTC() + needsUpdate = true + } + + if entry.endTime.Valid && entry.endTime.Time.Location() != time.UTC { + entry.endTime.Time = entry.endTime.Time.UTC() + needsUpdate = true + } + + if needsUpdate { + updates = append(updates, entry) + } + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating entries: %w", err) + } + + // Apply updates + for _, update := range updates { + _, err := tx.Exec( + "UPDATE time_entries SET start_time = ?, end_time = ? WHERE id = ?", + update.startTime, + update.endTime, + update.id, + ) + if err != nil { + return fmt.Errorf("failed to update entry %d: %w", update.id, err) + } + } + + return nil +} + +// migrateMilestonesTableToUTC converts all milestones timestamps to UTC +func (d *Database) migrateMilestonesTableToUTC(tx *sql.Tx) error { + // Get all milestones + rows, err := tx.Query("SELECT id, start_time, end_time FROM milestones") + if err != nil { + return fmt.Errorf("failed to query milestones: %w", err) + } + defer rows.Close() + + type milestoneUpdate struct { + id int64 + startTime time.Time + endTime sql.NullTime + } + + var updates []milestoneUpdate + + for rows.Next() { + var milestone milestoneUpdate + + if err := rows.Scan(&milestone.id, &milestone.startTime, &milestone.endTime); err != nil { + return fmt.Errorf("failed to scan milestone: %w", err) + } + + // Check if timestamps need conversion (not already UTC) + needsUpdate := false + + if milestone.startTime.Location() != time.UTC { + milestone.startTime = milestone.startTime.UTC() + needsUpdate = true + } + + if milestone.endTime.Valid && milestone.endTime.Time.Location() != time.UTC { + milestone.endTime.Time = milestone.endTime.Time.UTC() + needsUpdate = true + } + + if needsUpdate { + updates = append(updates, milestone) + } + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating milestones: %w", err) + } + + // Apply updates + for _, update := range updates { + _, err := tx.Exec( + "UPDATE milestones SET start_time = ?, end_time = ? WHERE id = ?", + update.startTime, + update.endTime, + update.id, + ) + if err != nil { + return fmt.Errorf("failed to update milestone %d: %w", update.id, err) + } + } + + return nil } \ No newline at end of file From cd4c6144f46801717aa734ab7e2d8dfafead9bf2 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Sun, 11 Jan 2026 16:45:29 -0800 Subject: [PATCH 2/2] Move migration logic to migrations.go Refactored database migration infrastructure from db.go to a new migrations.go file for better separation of concerns and maintainability. No functional changes were made; all migration-related functions and constants were relocated. --- internal/storage/db.go | 229 --------------------------------- internal/storage/migrations.go | 227 ++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 229 deletions(-) create mode 100644 internal/storage/migrations.go diff --git a/internal/storage/db.go b/internal/storage/db.go index f0fc112..636b853 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -15,12 +15,6 @@ type Database struct { db *sql.DB } -// Migration keys -// ! I'm adding this system so that future database migrations will be easier - Dylan -const ( - Migration001_UTCTimestamps = "001_utc_timestamps" -) - func Initialize() (*Database, error) { homeDir, err := os.UserHomeDir() @@ -804,226 +798,3 @@ func (d *Database) GetEntriesByMilestone(projectName, milestoneName string) ([]* func (d *Database) Close() error { return d.db.Close() } - -// Migration infrastructure - -// hasMigrationRun checks if a specific migration has already been executed -func (d *Database) hasMigrationRun(migrationKey string) (bool, error) { - var value string - err := d.db.QueryRow("SELECT value FROM settings WHERE key = ?", migrationKey).Scan(&value) - - if err == sql.ErrNoRows { - return false, nil - } - - if err != nil { - return false, fmt.Errorf("failed to check migration status: %w", err) - } - - return value == "completed", nil -} - -// markMigrationComplete marks a migration as completed -func (d *Database) markMigrationComplete(migrationKey string) error { - _, err := d.db.Exec( - "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", - migrationKey, - "completed", - time.Now().UTC(), - ) - - if err != nil { - return fmt.Errorf("failed to mark migration complete: %w", err) - } - - return nil -} - -// runMigrations executes all pending migrations -func (d *Database) runMigrations() error { - // Migration 1: Convert all timestamps to UTC - if err := d.migrateTimestampsToUTC(); err != nil { - return fmt.Errorf("timestamp UTC migration failed: %w", err) - } - - return nil -} - -// migrateTimestampsToUTC converts all existing timestamps from local timezone to UTC -func (d *Database) migrateTimestampsToUTC() error { - // Check if already migrated - completed, err := d.hasMigrationRun(Migration001_UTCTimestamps) - if err != nil { - return err - } - - if completed { - // Migration already completed, skip - return nil - } - - // Start transaction for safety - if anything fails, nothing changes - tx, err := d.db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - - // Ensure we rollback on any error - defer func() { - if err != nil { - tx.Rollback() - } - }() - - // Migrate time_entries table - if err = d.migrateTimeEntriesTableToUTC(tx); err != nil { - return fmt.Errorf("failed to migrate time_entries: %w", err) - } - - // Migrate milestones table - if err = d.migrateMilestonesTableToUTC(tx); err != nil { - return fmt.Errorf("failed to migrate milestones: %w", err) - } - - // Mark migration as complete (within the transaction) - _, err = tx.Exec( - "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", - Migration001_UTCTimestamps, - "completed", - time.Now().UTC(), - ) - if err != nil { - return fmt.Errorf("failed to mark migration complete: %w", err) - } - - // Commit transaction - only now do changes take effect - if err = tx.Commit(); err != nil { - return fmt.Errorf("failed to commit migration transaction: %w", err) - } - - return nil -} - -// migrateTimeEntriesTableToUTC converts all time_entries timestamps to UTC -func (d *Database) migrateTimeEntriesTableToUTC(tx *sql.Tx) error { - // Get all entries - rows, err := tx.Query("SELECT id, start_time, end_time FROM time_entries") - if err != nil { - return fmt.Errorf("failed to query time_entries: %w", err) - } - defer rows.Close() - - type entryUpdate struct { - id int64 - startTime time.Time - endTime sql.NullTime - } - - var updates []entryUpdate - - for rows.Next() { - var entry entryUpdate - - if err := rows.Scan(&entry.id, &entry.startTime, &entry.endTime); err != nil { - return fmt.Errorf("failed to scan entry: %w", err) - } - - // Check if start_time needs conversion (not already UTC) - needsUpdate := false - - if entry.startTime.Location() != time.UTC { - entry.startTime = entry.startTime.UTC() - needsUpdate = true - } - - if entry.endTime.Valid && entry.endTime.Time.Location() != time.UTC { - entry.endTime.Time = entry.endTime.Time.UTC() - needsUpdate = true - } - - if needsUpdate { - updates = append(updates, entry) - } - } - - if err := rows.Err(); err != nil { - return fmt.Errorf("error iterating entries: %w", err) - } - - // Apply updates - for _, update := range updates { - _, err := tx.Exec( - "UPDATE time_entries SET start_time = ?, end_time = ? WHERE id = ?", - update.startTime, - update.endTime, - update.id, - ) - if err != nil { - return fmt.Errorf("failed to update entry %d: %w", update.id, err) - } - } - - return nil -} - -// migrateMilestonesTableToUTC converts all milestones timestamps to UTC -func (d *Database) migrateMilestonesTableToUTC(tx *sql.Tx) error { - // Get all milestones - rows, err := tx.Query("SELECT id, start_time, end_time FROM milestones") - if err != nil { - return fmt.Errorf("failed to query milestones: %w", err) - } - defer rows.Close() - - type milestoneUpdate struct { - id int64 - startTime time.Time - endTime sql.NullTime - } - - var updates []milestoneUpdate - - for rows.Next() { - var milestone milestoneUpdate - - if err := rows.Scan(&milestone.id, &milestone.startTime, &milestone.endTime); err != nil { - return fmt.Errorf("failed to scan milestone: %w", err) - } - - // Check if timestamps need conversion (not already UTC) - needsUpdate := false - - if milestone.startTime.Location() != time.UTC { - milestone.startTime = milestone.startTime.UTC() - needsUpdate = true - } - - if milestone.endTime.Valid && milestone.endTime.Time.Location() != time.UTC { - milestone.endTime.Time = milestone.endTime.Time.UTC() - needsUpdate = true - } - - if needsUpdate { - updates = append(updates, milestone) - } - } - - if err := rows.Err(); err != nil { - return fmt.Errorf("error iterating milestones: %w", err) - } - - // Apply updates - for _, update := range updates { - _, err := tx.Exec( - "UPDATE milestones SET start_time = ?, end_time = ? WHERE id = ?", - update.startTime, - update.endTime, - update.id, - ) - if err != nil { - return fmt.Errorf("failed to update milestone %d: %w", update.id, err) - } - } - - return nil -} \ No newline at end of file diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go new file mode 100644 index 0000000..43c7f1c --- /dev/null +++ b/internal/storage/migrations.go @@ -0,0 +1,227 @@ +package storage + +import ( + "database/sql" + "fmt" + "time" +) + +// Migration keys +// ! I'm adding this system so that future database migrations will be easier - Dylan +const ( + Migration001_UTCTimestamps = "001_utc_timestamps" +) + +// runMigrations executes all pending migrations +func (d *Database) runMigrations() error { + // Migration 1: Convert all timestamps to UTC + if err := d.migrateTimestampsToUTC(); err != nil { + return fmt.Errorf("timestamp UTC migration failed: %w", err) + } + + return nil +} + +func (d *Database) hasMigrationRun(migrationKey string) (bool, error) { + var value string + err := d.db.QueryRow("SELECT value FROM settings WHERE key = ?", migrationKey).Scan(&value) + + if err == sql.ErrNoRows { + return false, nil + } + + if err != nil { + return false, fmt.Errorf("failed to check migration status: %w", err) + } + + return value == "completed", nil +} + +// markMigrationComplete marks a migration as completed +func (d *Database) markMigrationComplete(migrationKey string) error { + _, err := d.db.Exec( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", + migrationKey, + "completed", + time.Now().UTC(), + ) + + if err != nil { + return fmt.Errorf("failed to mark migration complete: %w", err) + } + + return nil +} + +func (d *Database) migrateTimestampsToUTC() error { + completed, err := d.hasMigrationRun(Migration001_UTCTimestamps) + if err != nil { + return err + } + + if completed { + // migration is already finished + return nil + } + + // start transaction + tx, err := d.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + // rollback changes if something explodes + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // migrate time_entries table + if err = d.migrateTimeEntriesTableToUTC(tx); err != nil { + return fmt.Errorf("failed to migrate time_entries: %w", err) + } + + // migrate milestones table + if err = d.migrateMilestonesTableToUTC(tx); err != nil { + return fmt.Errorf("failed to migrate milestones: %w", err) + } + + // mark migration as complete in transaction + _, err = tx.Exec( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", + Migration001_UTCTimestamps, + "completed", + time.Now().UTC(), + ) + if err != nil { + return fmt.Errorf("failed to mark migration complete: %w", err) + } + + // push changes to db + if err = tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration transaction: %w", err) + } + + return nil +} + +func (d *Database) migrateTimeEntriesTableToUTC(tx *sql.Tx) error { + rows, err := tx.Query("SELECT id, start_time, end_time FROM time_entries") + if err != nil { + return fmt.Errorf("failed to query time_entries: %w", err) + } + defer rows.Close() + + type entryUpdate struct { + id int64 + startTime time.Time + endTime sql.NullTime + } + + var updates []entryUpdate + + for rows.Next() { + var entry entryUpdate + + if err := rows.Scan(&entry.id, &entry.startTime, &entry.endTime); err != nil { + return fmt.Errorf("failed to scan entry: %w", err) + } + + // check if timestamp needs conversion + needsUpdate := false + + if entry.startTime.Location() != time.UTC { + entry.startTime = entry.startTime.UTC() + needsUpdate = true + } + + if entry.endTime.Valid && entry.endTime.Time.Location() != time.UTC { + entry.endTime.Time = entry.endTime.Time.UTC() + needsUpdate = true + } + + if needsUpdate { + updates = append(updates, entry) + } + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating entries: %w", err) + } + + // apply updates + for _, update := range updates { + _, err := tx.Exec( + "UPDATE time_entries SET start_time = ?, end_time = ? WHERE id = ?", + update.startTime, + update.endTime, + update.id, + ) + if err != nil { + return fmt.Errorf("failed to update entry %d: %w", update.id, err) + } + } + + return nil +} + +func (d *Database) migrateMilestonesTableToUTC(tx *sql.Tx) error { + rows, err := tx.Query("SELECT id, start_time, end_time FROM milestones") + if err != nil { + return fmt.Errorf("failed to query milestones: %w", err) + } + defer rows.Close() + + type milestoneUpdate struct { + id int64 + startTime time.Time + endTime sql.NullTime + } + + var updates []milestoneUpdate + + for rows.Next() { + var milestone milestoneUpdate + + if err := rows.Scan(&milestone.id, &milestone.startTime, &milestone.endTime); err != nil { + return fmt.Errorf("failed to scan milestone: %w", err) + } + + // check if timestamps is not already UTC + needsUpdate := false + + if milestone.startTime.Location() != time.UTC { + milestone.startTime = milestone.startTime.UTC() + needsUpdate = true + } + + if milestone.endTime.Valid && milestone.endTime.Time.Location() != time.UTC { + milestone.endTime.Time = milestone.endTime.Time.UTC() + needsUpdate = true + } + + if needsUpdate { + updates = append(updates, milestone) + } + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating milestones: %w", err) + } + + // apply updates + for _, update := range updates { + _, err := tx.Exec( + "UPDATE milestones SET start_time = ?, end_time = ? WHERE id = ?", + update.startTime, + update.endTime, + update.id, + ) + if err != nil { + return fmt.Errorf("failed to update milestone %d: %w", update.id, err) + } + } + + return nil +}