diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 0000000..051e054 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/DylanDevelops/tmpo/internal/export" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/spf13/cobra" +) + +var ( + exportFormat string + exportOutput string + exportProject string + exportToday bool + exportWeek bool +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export time entries", + Long: `Export time tracking data to different formats.`, + Run: func(cmd *cobra.Command, args []string) { + db, err := storage.Initialize() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + defer db.Close() + + var entries []*storage.TimeEntry + + if exportToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday + } + + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportProject != "" { + entries, err = db.GetEntriesByProject(exportProject) + } else { + entries, err = db.GetEntries(0) // all + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + if len(entries) == 0 { + fmt.Println("No entries to export.") + + os.Exit(0) + } + + filename := exportOutput + if filename == "" { + timestamp := time.Now().Format("2006-01-02") + ext := "csv" + + if exportFormat == "json" { + ext = "json" + } + + filename = fmt.Sprintf("tmpo-export-%s.%s", timestamp, ext) + } + + if exportFormat == "csv" && filepath.Ext(filename) != ".csv" { + filename += ".csv" + } else if exportFormat == "json" && filepath.Ext(filename) != ".json" { + filename += ".json" + } + + switch exportFormat { + case "csv": + err = export.ToCSV(entries, filename) + case "json": + err = export.ToJson(entries, filename) + default: + fmt.Fprintf(os.Stderr, "Error: Unknown format '%s'. Use 'csv' or 'json'\n", exportFormat) + + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + fmt.Printf("[tmpo] Exported %d entries to %s\n", len(entries), filename) + }, +} + +func init() { + rootCmd.AddCommand(exportCmd) + + exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "csv", "Export format (csv or json)") + exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "Output filename") + exportCmd.Flags().StringVarP(&exportProject, "project", "p", "", "Filter by project") + exportCmd.Flags().BoolVarP(&exportToday, "today", "t", false, "Export today's entries") + exportCmd.Flags().BoolVarP(&exportWeek, "week", "w", false, "Export this week's entries") +} diff --git a/cmd/log.go b/cmd/log.go new file mode 100644 index 0000000..5b36bb3 --- /dev/null +++ b/cmd/log.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/spf13/cobra" +) + +var ( + logLimit int + logProject string + logToday bool + logWeek bool +) + +var logCmd = &cobra.Command{ + Use: "log", + Short: "View time tracking history", + Long: `Display past time tracking entries with optional filtering.`, + Run: func(cmd *cobra.Command, args []string) { + db, err := storage.Initialize() + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + defer db.Close() + + var entries []*storage.TimeEntry + + if logToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday + } + + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logProject != "" { + entries, err = db.GetEntriesByProject(logProject) + } else { + entries, err = db.GetEntries(logLimit) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if len(entries) == 0 { + fmt.Println("No time entries found.") + + return + } + + fmt.Printf("\n[tmpo] Time Entries (%d total)\n\n", len(entries)) + + var totalDuration time.Duration + currentDate := "" + + for _, entry := range entries { + entryDate := entry.StartTime.Format("Mon, Jan 2, 2006") + if entryDate != currentDate { + if currentDate != "" { + fmt.Println() + } + + fmt.Printf("─── %s ───\n", entryDate) + currentDate = entryDate + } + + duration := entry.Duration() + totalDuration += duration + + timeRange := entry.StartTime.Format("3:04 PM") + if entry.EndTime != nil { + timeRange += " - " + entry.EndTime.Format("3:04 PM") + } else { + timeRange += " - (running)" + } + + fmt.Printf(" %s %-20s %s\n", timeRange, entry.ProjectName, formatDuration(duration)) + if entry.Description != "" { + fmt.Printf(" └─ %s\n", entry.Description) + } + } + + fmt.Printf("\n─────────────────────────────────────────\n") + fmt.Printf("Total Time: %s\n", formatDuration(totalDuration)) + }, +} + +func init() { + rootCmd.AddCommand(logCmd) + + logCmd.Flags().IntVarP(&logLimit, "limit", "l", 10, "Number of entries to show") + logCmd.Flags().StringVarP(&logProject, "project", "p", "", "Filter by project name") + logCmd.Flags().BoolVarP(&logToday, "today", "t", false, "Show today's entries") + logCmd.Flags().BoolVarP(&logWeek, "week", "w", false, "Show this week's entries") +} diff --git a/cmd/stats.go b/cmd/stats.go new file mode 100644 index 0000000..5b1dcbb --- /dev/null +++ b/cmd/stats.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/spf13/cobra" +) + +var ( + statsToday bool + statsWeek bool +) + +var statsCmd = &cobra.Command{ + Use: "stats", + Short: "Show time tracking statistics", + Long: `Display statistics and summaries of your time tracking data.`, + Run: func(cmd *cobra.Command, args []string) { + db, err := storage.Initialize() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + defer db.Close() + + var start, end time.Time + var periodName string + + if statsToday { + start = time.Now().Truncate(24 * time.Hour) + end = start.Add(24 * time.Hour) + periodName = "Today" + } else if statsWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + + start = now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end = start.AddDate(0, 0, 7) + periodName = "This week" + } else { + entries, err := db.GetEntries(0) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + ShowAllTimeStats(entries, db) + + return + } + + entries, err := db.GetEntriesByDateRange(start, end) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + os.Exit(1) + } + + ShowPeriodStats(entries, periodName) + }, +} + +// ShowPeriodStats prints aggregated statistics for a named period to standard +// output. Given a slice of *storage.TimeEntry and a human-readable periodName, +// the function: +// +// - returns early with a message if entries is empty, +// - computes and prints the total accumulated time and its hour equivalent, +// - prints the total number of entries, +// - aggregates time by project and prints a per-project line with duration and +// percentage of the total, +// - attempts to load configuration and, if a positive hourly rate is present, +// prints an estimated earnings line. +// +// Aggregation is done via a map[string]time.Duration; iteration order is +// therefore non-deterministic. Percentages are computed as projectSeconds / +// totalSeconds * 100, so if the total duration is zero the percentage values +// may be undefined (NaN/Inf). All output is produced using fmt. +func ShowPeriodStats(entries []*storage.TimeEntry, periodName string) { + if len(entries) == 0 { + fmt.Printf("No entries for %s.\n", periodName) + + return + } + + projectStats := make(map[string]time.Duration) + var totalDuration time.Duration + + for _, entry := range entries { + duration := entry.Duration() + projectStats[entry.ProjectName] += duration + totalDuration += duration + } + + fmt.Printf("\n[tmpo] Stats for %s\n\n", periodName) + fmt.Printf(" Total Time: %s (%.2f hours)\n", formatDuration(totalDuration), totalDuration.Hours()) + fmt.Printf(" Total Entries: %d\n\n", len(entries)) + + fmt.Println(" By Project:") + for project, duration := range projectStats { + percentage := (duration.Seconds() / totalDuration.Seconds()) * 100 + fmt.Printf(" %-20s %s (%.1f%%)\n", project, formatDuration(duration), percentage) + } + + cfg, _, _ := config.FindAndLoad() + if cfg != nil && cfg.HourlyRate > 0 { + earnings := totalDuration.Hours() * cfg.HourlyRate + fmt.Printf("\n Estimated Earnings: $%.2f (at $%.2f/hr)\n", earnings, cfg.HourlyRate) + } +} + +// ShowAllTimeStats prints aggregated all-time statistics to standard output. +// Given a slice of *storage.TimeEntry and a pointer to the database, the +// function: +// +// - returns early with a message if entries is empty, +// - computes and prints the total accumulated time and its hour equivalent, +// - prints the total number of entries and number of tracked projects, +// - aggregates time by project and prints a per-project line with duration and +// percentage of the total. +// +// The function fetches the list of projects from the provided database to +// determine the number of projects tracked. Aggregation is done via a +// map[string]time.Duration; iteration order is therefore non-deterministic. +// If the total duration is zero, percentage values may be undefined. All +// output is produced using fmt. +func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { + if len(entries) == 0 { + fmt.Println("No entries found.") + + return + } + + projectStats := make(map[string]time.Duration) + var totalDuration time.Duration + + for _, entry := range entries { + duration := entry.Duration() + projectStats[entry.ProjectName] += duration + totalDuration += duration + } + + projects, _ := db.GetAllProjects() + + fmt.Printf("\n[tmpo] All-Time Statistics\n") + fmt.Printf(" Total Time: %s (%.2f hours)\n", formatDuration(totalDuration), totalDuration.Hours()) + fmt.Printf(" Total Entries: %d\n", len(entries)) + fmt.Printf(" Projects Tracked: %d\n\n", len(projects)) + + fmt.Println(" By Project:") + for project, duration := range projectStats { + percentage := (duration.Seconds() / totalDuration.Seconds()) * 100 + fmt.Printf(" %-20s %s (%.1f%%)\n", project, formatDuration(duration), percentage) + } +} + +func init() { + rootCmd.AddCommand(statsCmd) + + statsCmd.Flags().BoolVarP(&statsToday, "today", "t", false, "Show today's stats") + statsCmd.Flags().BoolVarP(&statsWeek, "week", "w", false, "Show this week's stats") +} diff --git a/internal/export/csv.go b/internal/export/csv.go new file mode 100644 index 0000000..df5c162 --- /dev/null +++ b/internal/export/csv.go @@ -0,0 +1,67 @@ +package export + +import ( + "encoding/csv" + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" +) + +// ToCSV writes the provided slice of time entries to a CSV file at the given +// filename. The file is created (or truncated if it already exists) and a CSV +// writer is used to emit a header row followed by one record per entry. +// +// The CSV contains the following columns in order: +// - "Project" : entry.ProjectName +// - "Start Time" : entry.StartTime formatted as "2006-01-02 15:04:05" +// - "End Time" : entry.EndTime formatted as "2006-01-02 15:04:05" or +// an empty string if EndTime is nil +// - "Duration (hours)" : entry.Duration() expressed in hours, formatted +// with two decimal places +// - "Description" : entry.Description +// +// The function returns an error if the file cannot be created or if writing +// any header/record fails. The CSV writer is flushed before returning, and the +// file is closed via deferred cleanup. The caller should ensure the provided +// filename is writable. +func ToCSV(entries []*storage.TimeEntry, filename string) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create CSV file: %w", err) + } + + defer file.Close() + + writer := csv.NewWriter(file) + + defer writer.Flush() + + header := []string{"Project", "Start Time", "End Time", "Duration (hours)", "Description"} + if err := writer.Write(header); err != nil { + return fmt.Errorf("failed to write header: %w", err) + } + + for _, entry := range entries { + endTime := "" + if entry.EndTime != nil { + endTime = entry.EndTime.Format("2006-01-02 15:04:05") + } + + duration := entry.Duration().Hours() + + record := []string{ + entry.ProjectName, + entry.StartTime.Format("2006-01-02 15:04:05"), + endTime, + fmt.Sprintf("%.2f", duration), + entry.Description, + } + + if err := writer.Write(record); err != nil { + return fmt.Errorf("failed to write record: %w", err) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/export/json.go b/internal/export/json.go new file mode 100644 index 0000000..fe671f2 --- /dev/null +++ b/internal/export/json.go @@ -0,0 +1,73 @@ +package export + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" +) + +// ExportEntry represents a single time-tracking record prepared for JSON export. +// It contains the project name, the start timestamp, an optional end timestamp, +// the duration expressed in hours, and an optional human-readable description. +// +// Project is the associated project identifier or name. +// StartTime is the entry start timestamp as a string (for example, RFC3339). +// EndTime is the optional end timestamp; it will be omitted from JSON when empty. +// Duration is the total duration of the entry in hours as a floating-point value. +// Description is an optional text note; it will be omitted from JSON when empty. +type ExportEntry struct { + Project string `json:"project"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time,omitempty"` + Duration float64 `json:"duration_hours"` + Description string `json:"description,omitempty"` +} + +// ToJson writes the given time entries to filename in pretty-printed JSON. +// Each storage.TimeEntry is converted to an ExportEntry with these mappings: +// - Project: entry.ProjectName +// - StartTime: formatted using layout "2006-01-02T15:04:05Z07:00" (RFC3339-like) +// - EndTime: formatted using the same layout if entry.EndTime is non-nil; omitted otherwise +// - Duration: entry.Duration().Hours() (floating-point hours) +// - Description: entry.Description +// +// The function creates or truncates the target file, encodes the slice of +// ExportEntry values with json.Encoder and indentation, and closes the file +// before returning. It returns an error if the file cannot be created or if +// JSON encoding fails. Callers must ensure the destination path is writable. +func ToJson(entries []*storage.TimeEntry, filename string) error { + var exportEntries []ExportEntry + + for _, entry := range entries { + export := ExportEntry{ + Project: entry.ProjectName, + StartTime: entry.StartTime.Format("2006-01-02T15:04:05Z07:00"), + Duration: entry.Duration().Hours(), + Description: entry.Description, + } + + if entry.EndTime != nil { + export.EndTime = entry.EndTime.Format("2006-01-02T15:04:05Z07:00") + } + + exportEntries = append(exportEntries, export) + } + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create JSON file: %w", err) + } + + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + if err := encoder.Encode(exportEntries); err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/internal/storage/db.go b/internal/storage/db.go index 4204b33..a14a774 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -186,6 +186,172 @@ func (d *Database) GetEntry(id int64) (*TimeEntry, error) { return &entry, nil } +// GetEntries retrieves time entries from the Database. +// +// It returns time entries ordered by start_time in descending order. If limit > 0, +// at most `limit` entries are returned; if limit <= 0 all matching entries are returned. +// Each returned element is a pointer to a TimeEntry. The EndTime field of a TimeEntry +// will be nil when the corresponding end_time column in the database is NULL. +// +// The function performs a SQL query selecting id, project_name, start_time, end_time, +// and description. It returns a slice of entries and an error if the query or row +// scanning fails; any underlying error is wrapped. +func (d *Database) GetEntries(limit int) ([]*TimeEntry, error) { + query := ` + SELECT id, project_name, start_time, end_time, description + FROM time_entries + ORDER BY start_time DESC + ` + + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := d.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + defer rows.Close() + + var entries []*TimeEntry + + for rows.Next() { + var entry TimeEntry + var endTime sql.NullTime + + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + if err != nil { + return nil, fmt.Errorf("failed to scan entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + entries = append(entries, &entry) + } + + return entries, nil +} + +// GetEntriesByProject retrieves time entries for the specified projectName from the +// time_entries table. Results are ordered by start_time in descending order (newest first). +// +// For each row a TimeEntry is populated. If the end_time column is NULL the returned +// TimeEntry.EndTime will be nil; otherwise EndTime will point to the scanned time.Time. +// +// On success the function returns a slice of pointers to TimeEntry. If there are no +// matching rows the returned slice will have length 0 (it may be nil). On failure the +// function returns a non-nil error and a nil slice. Errors may originate from the +// query execution, row scanning, or row iteration. +func (d *Database) GetEntriesByProject(projectName string) ([]*TimeEntry, error) { + rows, err := d.db.Query(` + SELECT id, project_name, start_time, end_time, description + FROM time_entries + WHERE project_name = ? + ORDER BY start_time DESC + `, projectName) + + if err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + defer rows.Close() + + var entries []*TimeEntry + + for rows.Next() { + var entry TimeEntry + var endTime sql.NullTime + + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + if err != nil { + return nil, fmt.Errorf("failed to scan entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + entries = append(entries, &entry) + } + + return entries, nil +} + +// GetEntriesByDateRange retrieves time entries whose start_time falls between start and end (inclusive). +// Results are returned in descending order by start_time. +// The provided start and end times are passed to the database driver as-is; callers should ensure they use the intended timezone/representation. +// For rows with a NULL end_time the returned TimeEntry.EndTime will be nil; otherwise EndTime points to the parsed time value. +// Returns a slice of pointers to TimeEntry (which may be empty) or an error if the database query or row scanning fails. +func (d *Database) GetEntriesByDateRange(start, end time.Time) ([]*TimeEntry, error) { + rows, err := d.db.Query(` + SELECT id, project_name, start_time, end_time, description + FROM time_entries + WHERE start_time BETWEEN ? AND ? + ORDER BY start_time DESC + `, start, end) + + if err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + defer rows.Close() + + var entries []*TimeEntry + + for rows.Next() { + var entry TimeEntry + var endTime sql.NullTime + + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description) + if err != nil { + return nil, fmt.Errorf("failed to scan entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + entries = append(entries, &entry) + } + + return entries, nil +} + +// GetAllProjects retrieves all distinct project names from the time_entries table. +// The results are returned in ascending order by project_name. +// On success it returns a slice of project names (which will be empty if no projects exist) +// and a nil error. If the underlying database query or a row scan fails, it returns a +// non-nil error describing the failure. +func (d *Database) GetAllProjects() ([]string, error) { + rows, err := d.db.Query(` + SELECT DISTINCT project_name + FROM time_entries + ORDER BY project_name + `) + + if err != nil { + return nil, fmt.Errorf("failed to query projects: %w", err) + } + + defer rows.Close() + + var projects []string + + for rows.Next() { + var project string + if err := rows.Scan(&project); err != nil { + return nil, fmt.Errorf("failed to scan project: %w", err) + } + + projects = append(projects, project) + } + + return projects, nil +} + // Close closes the Database, releasing any underlying resources. // It delegates to the wrapped database's Close method and returns any error encountered. // After Close is called, the Database must not be used for further operations. diff --git a/internal/storage/models.go b/internal/storage/models.go index 6c70392..fa1c72e 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -2,6 +2,10 @@ package storage import "time" +// TimeEntry represents a recorded period of work on a project. +// It includes a unique identifier, the project name, the start time, +// an optional end time (nil indicates the entry is still in progress), +// and a free-form description of the work performed. type TimeEntry struct { ID int64 ProjectName string @@ -10,6 +14,9 @@ type TimeEntry struct { Description string } +// Duration returns the elapsed time for the TimeEntry. +// If EndTime is non-nil, it returns the difference EndTime.Sub(StartTime). +// If EndTime is nil (the entry is ongoing), it returns time.Since(StartTime). func (t *TimeEntry) Duration() time.Duration { if( t.EndTime == nil) { return time.Since(t.StartTime) @@ -18,6 +25,8 @@ func (t *TimeEntry) Duration() time.Duration { return t.EndTime.Sub(t.StartTime) } +// IsRunning reports whether the TimeEntry is currently running. +// It returns true when EndTime is nil, indicating no end timestamp has been set. func (t *TimeEntry) IsRunning() bool { return t.EndTime == nil } \ No newline at end of file