446 lines
14 KiB
Go
446 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "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)
|
|
}
|
|
|
|
log.Printf("INFO: Using database at: %s", 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 {
|
|
log.Printf("WARN: Failed to create index on start_time: %v", err)
|
|
}
|
|
|
|
return &TimeStore{db: db, dbPath: dbPath}, nil
|
|
}
|
|
|
|
func ensureDatabasePath(cfg 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, 0750); 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 {
|
|
log.Printf("INFO: Closing database connection to %s", 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 {
|
|
log.Printf("WARN: Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected)
|
|
}
|
|
return rowsAffected > 0, nil
|
|
}
|
|
|
|
func (ts *TimeStore) StartTracking(tag string) 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 {
|
|
log.Println("INFO: Stopped previous time entry.")
|
|
}
|
|
|
|
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)
|
|
}
|
|
log.Printf("INFO: Started tracking: %s at %s", tag, now.Format(time.RFC3339))
|
|
return nil
|
|
}
|
|
|
|
func (ts *TimeStore) StopTracking() error {
|
|
now := time.Now()
|
|
stopped, err := ts.stopCurrentEntry(now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if stopped {
|
|
log.Printf("INFO: Stopped tracking at %s", now.Format(time.RFC3339))
|
|
} else {
|
|
log.Println("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() // Aktuelle Zeit für laufende Einträge
|
|
|
|
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 { // Eintrag läuft noch
|
|
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
|
|
}
|
|
log.Printf("WARN: Unrecognized period string '%s'. Cannot calculate time range.", period)
|
|
return time.Time{}, time.Time{}
|
|
}
|
|
}
|
|
|
|
// func formatDuration(d time.Duration) string {
|
|
// d = d.Round(time.Second)
|
|
// h := int64(d.Hours())
|
|
// m := int64(d.Minutes()) % 60
|
|
// s := int64(d.Seconds()) % 60
|
|
// return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
|
// }
|
|
|
|
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)
|
|
}
|
|
|
|
totalDuration := time.Duration(0)
|
|
fmt.Println("------------------------------")
|
|
for _, tag := range tags {
|
|
duration := summary[tag]
|
|
fmt.Printf(" %-12s: %s\n", strings.Title(tag), formatDuration(duration))
|
|
totalDuration += duration
|
|
}
|
|
fmt.Println("------------------------------")
|
|
fmt.Printf(" Total : %s\n\n", formatDuration(totalDuration))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ts *TimeStore) ExportSummary(filename string) error {
|
|
log.Printf("INFO: 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)
|
|
log.Printf("INFO: 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)
|
|
}
|
|
log.Printf("INFO: 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 {
|
|
log.Println("WARN: No daily summaries generated for the export period.")
|
|
fmt.Println("No data available to generate the export for the specified period.")
|
|
return nil
|
|
}
|
|
log.Printf("INFO: Generated %d daily entries for the Excel export.", len(excelEntries))
|
|
|
|
if err := writeExcelSheet(excelEntries, filename); err != nil { // Aufruf der geänderten Funktion
|
|
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
|
|
}
|
|
|
|
log.Printf("INFO: 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) // Stelle sicher, dass der Tag klein geschrieben ist
|
|
|
|
location := date.Location() // Verwende die Zeitzone des übergebenen Datums
|
|
// Berechne Start (00:00:00 des Tages) und Ende (00:00:00 des nächsten Tages)
|
|
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")
|
|
|
|
log.Printf("INFO: Attempting to log '%s' for the full day %s", tag, dayStr)
|
|
|
|
// 1. Stoppe den aktuell laufenden Timer (falls vorhanden)
|
|
// Wir verwenden dayStart als Zeitpunkt für das Stoppen, um Konsistenz zu wahren
|
|
stopped, err := ts.stopCurrentEntry(dayStart)
|
|
if err != nil {
|
|
// Nur loggen, weitermachen. Der Nutzer will diesen Tag ja explizit setzen.
|
|
log.Printf("WARN: Failed to stop current entry before logging full day '%s': %v", tag, err)
|
|
} else if stopped {
|
|
log.Printf("INFO: 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() // Stellt sicher, dass bei Fehlern nichts gespeichert wird
|
|
|
|
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 {
|
|
// Spezifischere Fehlermeldung, falls es UNIQUE Constraints gäbe
|
|
return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err)
|
|
}
|
|
|
|
// Transaktion erfolgreich abschließen
|
|
if err = tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit transaction for full-day entry: %w", err)
|
|
}
|
|
|
|
log.Printf("INFO: 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", strings.Title(tag), dayStr) // Benutzerfeedback
|
|
return nil
|
|
}
|