refactor: refactor project structure to use golang best practices
This commit is contained in:
parent
5b16cef525
commit
4ed6a61b1d
10 changed files with 617 additions and 702 deletions
521
internal/store/export.go
Normal file
521
internal/store/export.go
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type DailySummary struct {
|
||||
Date string
|
||||
Day string
|
||||
WorkStart string
|
||||
WorkEnd string
|
||||
BreakDuration time.Duration
|
||||
WorkDuration time.Duration
|
||||
Tag string
|
||||
}
|
||||
|
||||
type ExcelEntry struct {
|
||||
Date string
|
||||
Day string
|
||||
WorkStart string
|
||||
WorkEnd string
|
||||
BreakDuration string
|
||||
Tag string
|
||||
}
|
||||
|
||||
func (s *Store) ExportSummary(ctx context.Context, 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 < ?
|
||||
AND (end_time IS NULL OR end_time > ?)
|
||||
ORDER BY start_time ASC;`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, yearEnd, yearStart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query entries for year export: %w", 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: %w", 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.", len(entries)))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) {
|
||||
dailyMap := make(map[string]*DailySummary)
|
||||
location := yearStart.Location()
|
||||
now := time.Now().In(location)
|
||||
|
||||
currentDay := yearStart
|
||||
for currentDay.Before(yearEnd) {
|
||||
dayStr := currentDay.Format("2006-01-02")
|
||||
weekday := currentDay.Weekday()
|
||||
tag := ""
|
||||
if weekday == time.Saturday || weekday == time.Sunday {
|
||||
tag = "free"
|
||||
}
|
||||
|
||||
dailyMap[dayStr] = &DailySummary{
|
||||
Date: dayStr,
|
||||
Day: weekday.String()[:3],
|
||||
Tag: tag,
|
||||
}
|
||||
currentDay = currentDay.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
fullDayTags := make(map[string]string)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.StartTime.IsZero() {
|
||||
slog.Warn("Skipping entry with zero start time", "ID", entry.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
startTime := entry.StartTime.In(location)
|
||||
endTime := entry.EndTime.Time.In(location)
|
||||
if !entry.EndTime.Valid {
|
||||
endTime = now
|
||||
}
|
||||
|
||||
if endTime.Before(yearStart) || startTime.After(yearEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
lowerTag := strings.ToLower(entry.Tag)
|
||||
isPotentiallyFullDaySpecialTag := false
|
||||
switch lowerTag {
|
||||
case "urlaub", "krank", "feiertag", "uni", "free":
|
||||
isPotentiallyFullDaySpecialTag = true
|
||||
}
|
||||
|
||||
if isPotentiallyFullDaySpecialTag {
|
||||
loopTimeForTag := startTime
|
||||
for loopTimeForTag.Before(endTime) || loopTimeForTag.Equal(endTime) {
|
||||
dayStr := loopTimeForTag.Format("2006-01-02")
|
||||
if _, exists := dailyMap[dayStr]; exists {
|
||||
existingTag := fullDayTags[dayStr]
|
||||
if shouldOverwriteTag(existingTag, lowerTag) {
|
||||
fullDayTags[dayStr] = lowerTag
|
||||
}
|
||||
}
|
||||
loopTimeForTag = time.Date(loopTimeForTag.Year(), loopTimeForTag.Month(), loopTimeForTag.Day(), 0, 0, 0, 0, location).Add(24 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
loopTime := startTime
|
||||
for loopTime.Before(endTime) {
|
||||
dayStr := loopTime.Format("2006-01-02")
|
||||
dayStart := time.Date(loopTime.Year(), loopTime.Month(), loopTime.Day(), 0, 0, 0, 0, location)
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
summary, exists := dailyMap[dayStr]
|
||||
if !exists {
|
||||
loopTime = dayEnd
|
||||
continue
|
||||
}
|
||||
|
||||
segmentStart := loopTime
|
||||
segmentEnd := endTime
|
||||
if segmentEnd.After(dayEnd) {
|
||||
segmentEnd = dayEnd
|
||||
}
|
||||
|
||||
segmentDuration := segmentEnd.Sub(segmentStart)
|
||||
if segmentDuration <= 0 {
|
||||
loopTime = dayEnd
|
||||
continue
|
||||
}
|
||||
|
||||
timeStr := segmentStart.Format("15:04:05")
|
||||
|
||||
switch lowerTag {
|
||||
case TagWork:
|
||||
summary.WorkDuration += segmentDuration
|
||||
if summary.WorkStart == "" || timeStr < summary.WorkStart {
|
||||
summary.WorkStart = timeStr
|
||||
}
|
||||
entryEndTimeOnThisDay := endTime
|
||||
if !endTime.Truncate(24 * time.Hour).Equal(dayStart) {
|
||||
entryEndTimeOnThisDay = segmentEnd
|
||||
}
|
||||
entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05")
|
||||
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
|
||||
summary.WorkEnd = entryEndTimeOnThisDayStr
|
||||
}
|
||||
if summary.Tag == "" || summary.Tag == "free" {
|
||||
summary.Tag = TagWork
|
||||
}
|
||||
|
||||
case TagBreak:
|
||||
summary.BreakDuration += segmentDuration
|
||||
default:
|
||||
summary.WorkDuration += segmentDuration
|
||||
if summary.WorkStart == "" || timeStr < summary.WorkStart {
|
||||
summary.WorkStart = timeStr
|
||||
}
|
||||
entryEndTimeOnThisDay := endTime
|
||||
if !endTime.Truncate(24 * time.Hour).Equal(dayStart) {
|
||||
entryEndTimeOnThisDay = segmentEnd
|
||||
}
|
||||
entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05")
|
||||
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
|
||||
summary.WorkEnd = entryEndTimeOnThisDayStr
|
||||
}
|
||||
if summary.Tag == "" || summary.Tag == "free" {
|
||||
summary.Tag = TagWork
|
||||
}
|
||||
}
|
||||
loopTime = dayEnd
|
||||
}
|
||||
}
|
||||
|
||||
for dayStr, specialTag := range fullDayTags {
|
||||
if summary, exists := dailyMap[dayStr]; exists {
|
||||
if shouldOverwriteTag(summary.Tag, specialTag) {
|
||||
summary.Tag = specialTag
|
||||
summary.WorkStart = ""
|
||||
summary.WorkEnd = ""
|
||||
summary.WorkDuration = 0
|
||||
summary.BreakDuration = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dailyMap, nil
|
||||
}
|
||||
|
||||
func shouldOverwriteTag(existingTag, newTag string) bool {
|
||||
if newTag != "" && (existingTag == "" || strings.ToLower(existingTag) == "free") {
|
||||
return true
|
||||
}
|
||||
if newTag == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
priority := map[string]int{
|
||||
"krank": 1,
|
||||
"feiertag": 1,
|
||||
"urlaub": 1,
|
||||
"uni": 2,
|
||||
"work": 3,
|
||||
"break": 99,
|
||||
"free": 100,
|
||||
}
|
||||
prioExisting, okExisting := priority[strings.ToLower(existingTag)]
|
||||
if !okExisting {
|
||||
prioExisting = 999
|
||||
}
|
||||
prioNew, okNew := priority[strings.ToLower(newTag)]
|
||||
if !okNew {
|
||||
prioNew = 999
|
||||
}
|
||||
|
||||
return prioNew < prioExisting || (prioNew == prioExisting && strings.ToLower(newTag) != "work")
|
||||
}
|
||||
|
||||
func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []ExcelEntry {
|
||||
excelEntries := make([]ExcelEntry, 0, len(dailySummaries))
|
||||
|
||||
dates := make([]string, 0, len(dailySummaries))
|
||||
for d := range dailySummaries {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
sort.Strings(dates)
|
||||
|
||||
for _, dateStr := range dates {
|
||||
summary := dailySummaries[dateStr]
|
||||
entry := ExcelEntry{
|
||||
Date: summary.Date,
|
||||
Day: summary.Day,
|
||||
WorkStart: summary.WorkStart,
|
||||
WorkEnd: summary.WorkEnd,
|
||||
BreakDuration: formatDuration(summary.BreakDuration),
|
||||
Tag: summary.Tag,
|
||||
}
|
||||
excelEntries = append(excelEntries, entry)
|
||||
}
|
||||
return excelEntries
|
||||
}
|
||||
|
||||
func getSollExcelTime(dayOfWeek string) any {
|
||||
var sollString string
|
||||
switch dayOfWeek {
|
||||
case "Mon", "Tue", "Thu", "Fri":
|
||||
sollString = "08:00"
|
||||
case "Wed":
|
||||
sollString = "04:00"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
sollDur, err := time.Parse("15:04", sollString)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("Could not parse hardcoded soll string '%s': %v", sollString, err))
|
||||
return nil
|
||||
}
|
||||
return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0)
|
||||
}
|
||||
|
||||
func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||
f := excelize.NewFile()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
slog.Error("Failed to close excel file handle", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sheetName := "Zeiten"
|
||||
if len(entries) > 0 {
|
||||
if t, err := time.Parse("2006-01-02", entries[0].Date); err == nil {
|
||||
sheetName = fmt.Sprintf("%d", t.Year())
|
||||
}
|
||||
}
|
||||
|
||||
index, err := f.NewSheet(sheetName)
|
||||
if err != nil {
|
||||
existingIndex, _ := f.GetSheetIndex(sheetName)
|
||||
if existingIndex == -1 {
|
||||
sheetName = "Sheet1"
|
||||
index, _ = f.GetSheetIndex(sheetName)
|
||||
if index == -1 {
|
||||
return fmt.Errorf("could not create sheet '%s': %w", sheetName, err)
|
||||
}
|
||||
} else {
|
||||
index = existingIndex
|
||||
}
|
||||
}
|
||||
|
||||
defaultSheetName := "Sheet1"
|
||||
defaultSheetIndex, _ := f.GetSheetIndex(defaultSheetName)
|
||||
if sheetName != defaultSheetName && defaultSheetIndex != -1 {
|
||||
f.DeleteSheet(defaultSheetName)
|
||||
}
|
||||
|
||||
f.SetCellValue(sheetName, "B1", "Arbeitszeiten "+sheetName)
|
||||
f.MergeCell(sheetName, "B1", "O1")
|
||||
|
||||
f.SetCellValue(sheetName, "B3", "Datum")
|
||||
f.SetCellValue(sheetName, "C3", "Tag")
|
||||
f.SetCellValue(sheetName, "D3", "Status / Zeit")
|
||||
f.MergeCell(sheetName, "D3", "E3")
|
||||
f.SetCellValue(sheetName, "G3", "Dauer")
|
||||
f.MergeCell(sheetName, "G3", "H3")
|
||||
f.SetCellValue(sheetName, "I3", "Pause")
|
||||
f.SetCellValue(sheetName, "J3", "Netto")
|
||||
f.SetCellValue(sheetName, "K3", "Soll")
|
||||
f.SetCellValue(sheetName, "L3", "Saldo")
|
||||
f.SetCellValue(sheetName, "N3", "Saldo Kumuliert")
|
||||
f.MergeCell(sheetName, "N3", "O3")
|
||||
|
||||
f.SetCellValue(sheetName, "D4", "von / Status")
|
||||
f.SetCellValue(sheetName, "E4", "bis")
|
||||
f.SetCellValue(sheetName, "G4", "brutto")
|
||||
f.SetCellValue(sheetName, "H4", "")
|
||||
f.SetCellValue(sheetName, "J4", "Ist (Netto)")
|
||||
f.SetCellValue(sheetName, "K4", "")
|
||||
f.SetCellValue(sheetName, "L4", "Tag")
|
||||
f.SetCellValue(sheetName, "N4", "Total")
|
||||
f.SetCellValue(sheetName, "O4", "")
|
||||
|
||||
toExcelTime := func(t time.Time) float64 {
|
||||
return float64(t.Hour())/24.0 + float64(t.Minute())/(24.0*60.0) + float64(t.Second())/(24.0*60.0*60.0)
|
||||
}
|
||||
|
||||
timeStyleCode := "hh:mm"
|
||||
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
|
||||
dateStyleCode := "dd.mm.yyyy"
|
||||
dateStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &dateStyleCode})
|
||||
saldoStyleCode := "[h]:mm;[RED]-[h]:mm"
|
||||
saldoStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &saldoStyleCode})
|
||||
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center"},
|
||||
})
|
||||
centerStyle, _ := f.NewStyle(&excelize.Style{Alignment: &excelize.Alignment{Horizontal: "center"}})
|
||||
|
||||
f.SetCellStyle(sheetName, "B3", "O4", headerStyle)
|
||||
f.SetCellStyle(sheetName, "B1", "O1", headerStyle)
|
||||
|
||||
startRow := 6
|
||||
for i, entry := range entries {
|
||||
row := startRow + i
|
||||
rowStr := fmt.Sprintf("%d", row)
|
||||
tagLower := strings.ToLower(entry.Tag)
|
||||
|
||||
dateValue, err := time.Parse("2006-01-02", entry.Date)
|
||||
if err == nil {
|
||||
f.SetCellValue(sheetName, "B"+rowStr, dateValue)
|
||||
f.SetCellStyle(sheetName, "B"+rowStr, "B"+rowStr, dateStyle)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "B"+rowStr, entry.Date)
|
||||
}
|
||||
f.SetCellValue(sheetName, "C"+rowStr, entry.Day)
|
||||
|
||||
sollExcelTime := getSollExcelTime(entry.Day)
|
||||
if sollExcelTime != nil {
|
||||
f.SetCellValue(sheetName, "K"+rowStr, sollExcelTime)
|
||||
f.SetCellStyle(sheetName, "K"+rowStr, "K"+rowStr, timeStyle)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "K"+rowStr, "")
|
||||
}
|
||||
|
||||
switch tagLower {
|
||||
case TagWork, "":
|
||||
if entry.WorkStart != "" && entry.WorkEnd != "" {
|
||||
startTime, _ := time.Parse("15:04:05", entry.WorkStart)
|
||||
endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
|
||||
startExcelTime := toExcelTime(startTime)
|
||||
endExcelTime := toExcelTime(endTime)
|
||||
if endExcelTime < startExcelTime {
|
||||
endExcelTime += 1.0
|
||||
}
|
||||
|
||||
f.SetCellValue(sheetName, "D"+rowStr, startExcelTime)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "D"+rowStr, timeStyle)
|
||||
f.SetCellValue(sheetName, "E"+rowStr, endExcelTime)
|
||||
f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle)
|
||||
|
||||
f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row))
|
||||
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle)
|
||||
|
||||
breakDur, _ := time.Parse("15:04:05", entry.BreakDuration)
|
||||
breakExcelTime := toExcelTime(breakDur)
|
||||
thirtyMinBreak := float64(30) / (24 * 60)
|
||||
if breakExcelTime < thirtyMinBreak {
|
||||
breakExcelTime = thirtyMinBreak
|
||||
}
|
||||
f.SetCellValue(sheetName, "I"+rowStr, breakExcelTime)
|
||||
f.SetCellStyle(sheetName, "I"+rowStr, "I"+rowStr, timeStyle)
|
||||
|
||||
f.SetCellFormula(sheetName, "J"+rowStr, fmt.Sprintf("MAX(0, G%d-I%d)", row, row))
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
}
|
||||
|
||||
case "urlaub", "uni":
|
||||
text := ""
|
||||
if tagLower == "urlaub" {
|
||||
text = "Urlaub"
|
||||
} else {
|
||||
text = "Hochschule"
|
||||
}
|
||||
f.SetCellValue(sheetName, "D"+rowStr, text)
|
||||
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
|
||||
|
||||
if sollExcelTime != nil {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
}
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
case "feiertag", "krank":
|
||||
text := ""
|
||||
if tagLower == "feiertag" {
|
||||
text = "Feiertag"
|
||||
} else {
|
||||
text = "Krank"
|
||||
}
|
||||
f.SetCellValue(sheetName, "D"+rowStr, text)
|
||||
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
|
||||
|
||||
if sollExcelTime != nil {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
}
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
case "free":
|
||||
f.SetCellValue(sheetName, "D"+rowStr, "")
|
||||
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
default:
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
}
|
||||
|
||||
f.SetCellFormula(sheetName, "L"+rowStr, fmt.Sprintf("J%d-K%d", row, row))
|
||||
f.SetCellStyle(sheetName, "L"+rowStr, "M"+rowStr, saldoStyle)
|
||||
|
||||
if i == 0 {
|
||||
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("L%d", row))
|
||||
} else {
|
||||
prevSaldoTotalCell := fmt.Sprintf("N%d", row-1)
|
||||
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("%s+L%d", prevSaldoTotalCell, row))
|
||||
}
|
||||
f.SetCellStyle(sheetName, "N"+rowStr, "O"+rowStr, saldoStyle)
|
||||
}
|
||||
|
||||
f.SetColWidth(sheetName, "B", "B", 12)
|
||||
f.SetColWidth(sheetName, "C", "C", 5)
|
||||
f.SetColWidth(sheetName, "D", "E", 10)
|
||||
f.SetColWidth(sheetName, "F", "F", 2)
|
||||
f.SetColWidth(sheetName, "G", "H", 9)
|
||||
f.SetColWidth(sheetName, "I", "I", 9)
|
||||
f.SetColWidth(sheetName, "J", "J", 9)
|
||||
f.SetColWidth(sheetName, "K", "K", 9)
|
||||
f.SetColWidth(sheetName, "L", "M", 9)
|
||||
f.SetColWidth(sheetName, "N", "O", 10)
|
||||
|
||||
f.SetActiveSheet(index)
|
||||
if err := f.SaveAs(name); err != nil {
|
||||
return fmt.Errorf("failed to save excel file as '%s': %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
376
internal/store/store.go
Normal file
376
internal/store/store.go
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 Store struct {
|
||||
db *sql.DB
|
||||
dbPath string
|
||||
}
|
||||
|
||||
func NewStore() (*Store, error) {
|
||||
dbPath, err := ensureDatabasePath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not determine database path: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Using database at:", "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)
|
||||
}
|
||||
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
return &Store{db: db, dbPath: dbPath}, nil
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tag TEXT NOT NULL CHECK(tag <> ''),
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NULL,
|
||||
CHECK (end_time IS NULL OR end_time >= start_time)
|
||||
);`
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return 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 nil
|
||||
}
|
||||
|
||||
func ensureDatabasePath() (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")
|
||||
if err := os.MkdirAll(workConfigDir, 0o750); err != nil {
|
||||
return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err)
|
||||
}
|
||||
return filepath.Join(workConfigDir, "worktime.sqlite"), nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s.db != nil {
|
||||
slog.Debug("Closing database connection", "path", s.dbPath)
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) stopCurrentEntry(ctx context.Context, now time.Time) (bool, error) {
|
||||
query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;`
|
||||
result, err := s.db.ExecContext(ctx, 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: %w", err)
|
||||
}
|
||||
return rowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) StartTracking(ctx context.Context, tag string) error {
|
||||
if tag == "" {
|
||||
return fmt.Errorf("cannot start tracking with an empty tag")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
stopped, err := s.stopCurrentEntry(ctx, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stopped {
|
||||
slog.Info("Stopped previous time entry.")
|
||||
}
|
||||
|
||||
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);`
|
||||
_, err = s.db.ExecContext(ctx, 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", tag))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) StopTracking(ctx context.Context) error {
|
||||
now := time.Now()
|
||||
stopped, err := s.stopCurrentEntry(ctx, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 (s *Store) LogFullDay(ctx context.Context, 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)
|
||||
|
||||
_, err := s.stopCurrentEntry(ctx, dayStart)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to stop current entry before logging full day", "error", err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
|
||||
if _, err := tx.ExecContext(ctx, query, tag, dayStart, dayEnd); err != nil {
|
||||
return fmt.Errorf("failed to insert full-day entry: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
titleCaser := cases.Title(language.English)
|
||||
slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Date='%s'", tag, dayStart.Format("2006-01-02")))
|
||||
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStart.Format("2006-01-02"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetEntriesInRange(ctx context.Context, 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 := s.db.QueryContext(ctx, 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 (s *Store) CalculateSummary(ctx context.Context, 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 > ?)
|
||||
AND start_time < ?
|
||||
ORDER BY start_time ASC;`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query entries: %w", 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 entry: %w", err)
|
||||
}
|
||||
|
||||
effStart := entry.StartTime
|
||||
if effStart.Before(start) {
|
||||
effStart = start
|
||||
}
|
||||
effEnd := now
|
||||
if entry.EndTime.Valid {
|
||||
effEnd = entry.EndTime.Time
|
||||
}
|
||||
if effEnd.After(end) {
|
||||
effEnd = end
|
||||
}
|
||||
|
||||
if effEnd.After(effStart) {
|
||||
summary[entry.Tag] += effEnd.Sub(effStart)
|
||||
}
|
||||
}
|
||||
return summary, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ShowSummary(ctx context.Context, period string) error {
|
||||
summary, err := s.CalculateSummary(ctx, period)
|
||||
if err != nil {
|
||||
return 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 formatDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = -d
|
||||
sign := "-"
|
||||
d = d.Round(time.Second)
|
||||
h := int64(d.Hours())
|
||||
m := int64(d.Minutes()) % 60
|
||||
s := int64(d.Seconds()) % 60
|
||||
return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
|
||||
}
|
||||
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 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 (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue