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..636b853 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -88,7 +88,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 +132,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 +162,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 +265,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 +547,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 +568,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 +590,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 +742,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 +797,4 @@ func (d *Database) GetEntriesByMilestone(projectName, milestoneName string) ([]* func (d *Database) Close() error { return d.db.Close() -} \ 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 +}