work/store.go
Patryk Hegenberg c0a83b5892
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
feat: add possibility to track time in timewarrior as well
2025-10-10 09:07:15 +02:00

442 lines
13 KiB
Go

package main
import (
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
_ "modernc.org/sqlite"
)
const (
TagWork = "work"
TagBreak = "break"
)
type TimeEntry struct {
ID int64
Tag string
StartTime time.Time
EndTime sql.NullTime
}
type TimeStore struct {
db *sql.DB
dbPath string
}
func NewTimeStore(cfg Config) (*TimeStore, error) {
dbPath, err := ensureDatabasePath(cfg)
if err != nil {
return nil, fmt.Errorf("could not determine database path: %w", err)
}
slog.Info("Using database at:", "Database Path", dbPath)
db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath))
if err != nil {
return nil, fmt.Errorf("failed to open database '%s': %w", dbPath, err)
}
if err = db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err)
}
createTableSQL := `
CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT NOT NULL CHECK(tag <> ''), -- Stelle sicher, dass Tag nicht leer ist
start_time DATETIME NOT NULL,
end_time DATETIME NULL,
-- Optional: Stelle sicher, dass nur ein Eintrag NULL end_time haben kann (falls DB unterstützt)
-- UNIQUE (end_time) WHERE end_time IS NULL -- SQLite unterstützt dies nicht direkt
CHECK (end_time IS NULL OR end_time >= start_time) -- Endzeit muss nach Startzeit liegen
);`
if _, err = db.Exec(createTableSQL); err != nil {
db.Close()
return nil, fmt.Errorf("failed to create table 'time_entries': %w", err)
}
createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);`
if _, err = db.Exec(createIndexSQL); err != nil {
slog.Warn("Failed to create index on start_time:", "Error:", err)
}
return &TimeStore{db: db, dbPath: dbPath}, nil
}
func ensureDatabasePath(_ Config) (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("could not get user config dir: %w", err)
}
workConfigDir := filepath.Join(configDir, "work")
dbPath := filepath.Join(workConfigDir, "worktime.sqlite")
if err := os.MkdirAll(workConfigDir, 0o750); err != nil {
return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err)
}
return dbPath, nil
}
func (ts *TimeStore) Close() error {
if ts.db != nil {
slog.Info("Closing database connection", "Database Path", ts.dbPath)
return ts.db.Close()
}
return nil
}
func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) {
query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;`
result, err := ts.db.Exec(query, now)
if err != nil {
return false, fmt.Errorf("failed to execute stop current entry query: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("failed to get affected rows after stopping entry: %w", err)
}
if rowsAffected > 1 {
slog.Warn(fmt.Sprintf("Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected))
}
return rowsAffected > 0, nil
}
func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error {
if tag == "" {
return fmt.Errorf("cannot start tracking with an empty tag")
}
now := time.Now()
stopped, err := ts.stopCurrentEntry(now)
if err != nil {
return err
}
if stopped {
slog.Info("Stopped previous time entry.")
}
if !withoutTimew {
runCommand("timew", "start", "work")
}
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);`
_, err = ts.db.Exec(query, tag, now)
if err != nil {
return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err)
}
slog.Info(fmt.Sprintf("Started tracking: %s at %s", tag, now.Format(time.RFC3339)))
return nil
}
func (ts *TimeStore) StopTracking(withoutTimew bool) error {
now := time.Now()
stopped, err := ts.stopCurrentEntry(now)
if err != nil {
return err
}
if !withoutTimew {
runCommand("timew", "stop", "work")
}
if stopped {
slog.Info(fmt.Sprintf("Stopped tracking at %s", now.Format(time.RFC3339)))
} else {
slog.Info("No active time entry found to stop.")
}
return nil
}
func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error) {
if start.IsZero() || end.IsZero() || end.Before(start) {
return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end)
}
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE start_time >= ? AND start_time < ?
ORDER BY start_time ASC;`
rows, err := ts.db.Query(query, start, end)
if err != nil {
return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, err)
}
defer rows.Close()
var entries []TimeEntry
for rows.Next() {
var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return nil, fmt.Errorf("failed to scan entry row: %w", err)
}
entries = append(entries, entry)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error during row iteration: %w", err)
}
return entries, nil
}
func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, error) {
start, end := getTimeRangeFromPeriod(period)
if start.IsZero() {
return nil, fmt.Errorf("invalid period string: '%s'", period)
}
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE (end_time IS NULL OR end_time > ?) -- Endet nach dem Start des Zeitraums
AND start_time < ? -- Beginnt vor dem Ende des Zeitraums
ORDER BY start_time ASC;`
rows, err := ts.db.Query(query, start, end)
if err != nil {
return nil, fmt.Errorf("failed to query overlapping entries for range [%v, %v): %w", start, end, err)
}
defer rows.Close()
summary := make(map[string]time.Duration)
now := time.Now()
for rows.Next() {
var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return nil, fmt.Errorf("failed to scan overlapping entry row: %w", err)
}
effectiveStart := entry.StartTime
if effectiveStart.Before(start) {
effectiveStart = start
}
effectiveEnd := entry.EndTime.Time
if !entry.EndTime.Valid {
effectiveEnd = now
}
if effectiveEnd.After(end) {
effectiveEnd = end
}
if effectiveEnd.After(effectiveStart) {
duration := effectiveEnd.Sub(effectiveStart)
summary[entry.Tag] += duration
}
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error during overlapping row iteration: %w", err)
}
return summary, nil
}
func getTimeRangeFromPeriod(period string) (time.Time, time.Time) {
now := time.Now()
year, month, day := now.Date()
loc := now.Location()
normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":"))
switch normalizedPeriod {
case "week":
weekday := now.Weekday()
daysToMonday := time.Duration(weekday - time.Monday)
if weekday == time.Sunday {
daysToMonday = 6
}
start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour)
end := start.Add(7 * 24 * time.Hour)
return start, end
case "month":
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return start, end
case "year":
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
end := start.AddDate(1, 0, 0)
return start, end
case "day", "today":
start := time.Date(year, month, day, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
default:
if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil {
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
}
slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period))
return time.Time{}, time.Time{}
}
}
func (ts *TimeStore) ShowSummary(period string) error {
summary, err := ts.CalculateSummary(period)
if err != nil {
return fmt.Errorf("error calculating summary for '%s': %w", period, err)
}
start, _ := getTimeRangeFromPeriod(period)
titlePeriod := period
if !start.IsZero() {
_, end := getTimeRangeFromPeriod(period)
if period == ":day" || period == "today" {
titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02"))
} else if period == ":week" {
titlePeriod = fmt.Sprintf("Week starting %s", start.Format("Mon, 2006-01-02"))
} else if period == ":month" {
titlePeriod = fmt.Sprintf("Month %s", start.Format("January 2006"))
} else if period == ":year" {
titlePeriod = fmt.Sprintf("Year %d", start.Year())
} else if _, err := time.Parse("2006-01-02", period); err == nil {
titlePeriod = fmt.Sprintf("Day %s", start.Format("2006-01-02"))
} else {
titlePeriod = fmt.Sprintf("Period '%s' (%s to %s)", period, start.Format("2006-01-02"), end.Format("2006-01-02"))
}
}
fmt.Printf("\nTime Summary for %s\n", titlePeriod)
if len(summary) == 0 {
fmt.Println(" No recorded time entries for this period.")
return nil
}
tags := make([]string, 0, len(summary))
for tag := range summary {
tags = append(tags, tag)
}
titleCaser := cases.Title(language.English)
totalDuration := time.Duration(0)
fmt.Println("------------------------------")
for _, tag := range tags {
duration := summary[tag]
fmt.Printf(" %-12s: %s\n", titleCaser.String(tag), formatDuration(duration))
totalDuration += duration
}
fmt.Println("------------------------------")
fmt.Printf(" Total : %s\n\n", formatDuration(totalDuration))
return nil
}
func (ts *TimeStore) ExportSummary(filename string) error {
slog.Info(fmt.Sprintf("Starting export to '%s'...", filename))
currentYear := time.Now().Year()
location := time.Local
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location)
yearEnd := yearStart.AddDate(1, 0, 0)
slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02")))
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE start_time < ? -- Beginnt vor Anfang des nächsten Jahres
AND (end_time IS NULL OR end_time > ?) -- Endet nach Anfang des Jahres
ORDER BY start_time ASC;`
rows, err := ts.db.Query(query, yearEnd, yearStart)
if err != nil {
return fmt.Errorf("failed to query entries for year export [%v, %v): %w", yearStart, yearEnd, err)
}
defer rows.Close()
var entries []TimeEntry
for rows.Next() {
var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return fmt.Errorf("failed to scan entry row (ID: %d) for export: %w", entry.ID, err)
}
entries = append(entries, entry)
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error during export row iteration: %w", err)
}
slog.Info(fmt.Sprintf("Found %d potentially relevant time entries for year %d.", len(entries), currentYear))
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
if err != nil {
return fmt.Errorf("failed to aggregate entries for export: %w", err)
}
excelEntries := convertDailyToExcelEntries(dailySummaries)
if len(excelEntries) == 0 {
slog.Warn("No daily summaries generated for the export period.")
fmt.Println("No data available to generate the export for the specified period.")
return nil
}
slog.Info(fmt.Sprintf("Generated %d daily entries for the Excel export.", len(excelEntries)))
if err := writeExcelSheet(excelEntries, filename); err != nil {
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
}
slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename))
fmt.Printf("Successfully exported timetable to %s\n", filename)
return nil
}
func (ts *TimeStore) LogFullDay(tag string, date time.Time) error {
if tag == "" {
return fmt.Errorf("cannot log full day with an empty tag")
}
tag = strings.ToLower(tag)
location := date.Location()
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
dayEnd := dayStart.Add(24 * time.Hour)
dayStr := dayStart.Format("2006-01-02")
slog.Info(fmt.Sprintf("Attempting to log '%s' for the full day %s", tag, dayStr))
stopped, err := ts.stopCurrentEntry(dayStart)
if err != nil {
slog.Warn(fmt.Sprintf("Failed to stop current entry before logging full day '%s': %v", tag, err))
} else if stopped {
slog.Info(fmt.Sprintf("Stopped active timer before logging '%s' for %s.", tag, dayStr))
}
tx, err := ts.db.Begin()
if err != nil {
return fmt.Errorf("could not begin transaction to log full day: %w", err)
}
defer tx.Rollback()
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
stmt, err := tx.Prepare(query)
if err != nil {
return fmt.Errorf("could not prepare statement to log full day: %w", err)
}
defer stmt.Close()
_, err = stmt.Exec(tag, dayStart, dayEnd)
if err != nil {
return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction for full-day entry: %w", err)
}
titleCaser := cases.Title(language.English)
slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Start='%s', End='%s'", tag, dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)))
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr)
return nil
}