1package core 2 3import ( 4 "errors" 5 "io" 6 "os" 7 "path/filepath" 8 "strconv" 9 "time" 10 11 "github.com/dominikbraun/timetrace/config" 12 "github.com/dominikbraun/timetrace/out" 13) 14 15const ( 16 BakFileExt = ".bak" 17) 18 19var ( 20 ErrNoEndTime = errors.New("no end time for last record") 21 ErrTrackingNotStarted = errors.New("start tracking first") 22 ErrAllDirectoriesEmpty = errors.New("all directories empty") 23) 24 25type Report struct { 26 Current *Record 27 TrackedTimeCurrent *time.Duration 28 TrackedTimeToday time.Duration 29 BreakTimeToday time.Duration 30} 31 32// Filesystem represents a filesystem used for storing and loading resources. 33type Filesystem interface { 34 ProjectFilepath(key string) string 35 ProjectBackupFilepath(key string) string 36 ProjectFilepaths() ([]string, error) 37 ProjectBackupFilepaths() ([]string, error) 38 RecordFilepath(start time.Time) string 39 RecordBackupFilepath(start time.Time) string 40 RecordFilepaths(dir string, less func(a, b string) bool) ([]string, error) 41 RecordDirs() ([]string, error) 42 ReportDir() string 43 RecordDirFromDate(date time.Time) string 44 EnsureDirectories() error 45 EnsureRecordDir(date time.Time) error 46 WriteReport(path string, data []byte) error 47} 48 49type Timetrace struct { 50 config *config.Config 51 fs Filesystem 52 formatter *Formatter 53} 54 55func New(config *config.Config, fs Filesystem) *Timetrace { 56 return &Timetrace{ 57 config: config, 58 fs: fs, 59 formatter: &Formatter{ 60 useDecimalHours: config.UseDecimalHours, 61 use12Hours: config.Use12Hours, 62 }, 63 } 64} 65 66// Start starts tracking time for the given project key. This will create a new 67// record with the current time as start time. 68// 69// Since parallel work isn't supported, the previous work must be stopped first. 70func (t *Timetrace) Start(projectKey string, isBillable bool, tags []string) error { 71 latestRecord, err := t.LoadLatestRecord() 72 if err != nil && !errors.Is(err, ErrAllDirectoriesEmpty) { 73 return err 74 } 75 76 // If there is no end time of the latest record, the user has to stop first. 77 if latestRecord != nil && latestRecord.End == nil { 78 return ErrNoEndTime 79 } 80 81 var project *Project 82 83 if projectKey != "" { 84 if project, err = t.LoadProject(projectKey); err != nil { 85 return err 86 } 87 } 88 89 record := Record{ 90 Start: time.Now(), 91 Project: project, 92 IsBillable: isBillable, 93 Tags: tags, 94 } 95 96 return t.SaveRecord(record, false) 97} 98 99// Status calculates and returns a status report. 100// 101// If the user isn't tracking time at the moment of calling this function, the 102// Report.Current and Report.TrackedTimeCurrent fields will be nil. If the user 103// hasn't tracked time today, ErrTrackingNotStarted will be returned. 104func (t *Timetrace) Status() (*Report, error) { 105 now := time.Now() 106 107 firstRecord, err := t.loadOldestRecord(time.Now()) 108 if err != nil { 109 return nil, err 110 } 111 112 if firstRecord == nil { 113 return nil, ErrTrackingNotStarted 114 } 115 116 latestRecord, err := t.LoadLatestRecord() 117 if err != nil { 118 return nil, err 119 } 120 121 trackedTimeToday, err := t.trackedTime(now) 122 if err != nil { 123 return nil, err 124 } 125 126 breakTimeToday, err := t.breakTime(now) 127 if err != nil { 128 return nil, err 129 } 130 131 report := &Report{ 132 TrackedTimeToday: trackedTimeToday, 133 BreakTimeToday: breakTimeToday, 134 } 135 136 // If the latest record has been stopped, there is no active time tracking. 137 // Therefore, just calculate the tracked time of today and return. 138 if latestRecord.End != nil { 139 return report, nil 140 } 141 142 report.Current = latestRecord 143 144 // If the latest record has not been stopped yet, time tracking is active. 145 // Calculate the time tracked for the current record and for today. 146 trackedTimeCurrent := latestRecord.Duration() 147 report.TrackedTimeCurrent = &trackedTimeCurrent 148 149 return report, nil 150} 151 152func (t *Timetrace) breakTime(date time.Time) (time.Duration, error) { 153 records, err := t.loadAllRecordsSortedAscending(date) 154 if err != nil { 155 return 0, err 156 } 157 158 // add up the time between records 159 var breakTime time.Duration 160 for i := 0; i < len(records)-1; i++ { 161 breakTime += records[i+1].Start.Sub(*records[i].End) 162 } 163 164 return breakTime, nil 165} 166 167// Stop stops the time tracking and marks the current record as ended. 168func (t *Timetrace) Stop() error { 169 latestRecord, err := t.LoadLatestRecord() 170 if err != nil { 171 return err 172 } 173 174 if latestRecord == nil || latestRecord.End != nil { 175 return ErrTrackingNotStarted 176 } 177 178 end := time.Now() 179 latestRecord.End = &end 180 181 return t.SaveRecord(*latestRecord, true) 182} 183 184// Report generates a report of tracked times 185// 186// The report can be filtered by the given Filter* funcs. By default 187// all records of all projects will be collected. Interaction with the report 188// can be done via the Reporter instance 189func (t *Timetrace) Report(filter ...func(*Record) bool) (*Reporter, error) { 190 recordDirs, err := t.fs.RecordDirs() 191 if err != nil { 192 return nil, err 193 } 194 // collect records 195 var result = make([]*Record, 0) 196 for _, dir := range recordDirs { 197 records, err := t.loadFromRecordDir(dir, filter...) 198 if err != nil { 199 return nil, err 200 } 201 result = append(result, records...) 202 } 203 204 var reporter = Reporter{ 205 t: t, 206 report: make(map[string][]*Record), 207 totals: make(map[string]time.Duration), 208 } 209 // prepare data for serialization 210 reporter.sortAndMerge(result) 211 return &reporter, nil 212} 213 214// WriteReport forwards the byte slice to the fs but checks in prior for 215// the correct output path. If the user has not provided one the config.ReportPath 216// will be used if not set path falls-back to $HOME/.timetrace/reports/report-<time.unix> 217func (t *Timetrace) WriteReport(path string, data []byte) error { 218 return t.fs.WriteReport(path, data) 219} 220 221func (t *Timetrace) EnsureDirectories() error { 222 return t.fs.EnsureDirectories() 223} 224 225func (t *Timetrace) Config() *config.Config { 226 return t.config 227} 228 229func (t *Timetrace) Formatter() *Formatter { 230 return t.formatter 231} 232 233func (t *Timetrace) trackedTime(date time.Time) (time.Duration, error) { 234 records, err := t.loadAllRecords(date) 235 if err != nil { 236 return 0, err 237 } 238 239 var trackedTime time.Duration 240 241 for _, record := range records { 242 trackedTime += record.Duration() 243 } 244 245 return trackedTime, nil 246} 247 248func (t *Timetrace) isDirEmpty(dir string) (bool, error) { 249 openedDir, err := os.Open(dir) 250 if err != nil { 251 return false, err 252 } 253 defer openedDir.Close() 254 255 // Attempt to read 1 file's name, if it fails with 256 // EOF, directory is empty 257 _, err = openedDir.Readdirnames(1) 258 if err == io.EOF { 259 return true, nil 260 } 261 262 return false, err 263} 264 265func (t *Timetrace) latestNonEmptyDir(dirs []string) (string, error) { 266 for i := len(dirs) - 1; i >= 0; i-- { 267 isEmpty, err := t.isDirEmpty(dirs[i]) 268 if err != nil { 269 return "", err 270 } 271 272 if !isEmpty { 273 return dirs[i], nil 274 } 275 } 276 277 return "", ErrAllDirectoriesEmpty 278} 279 280func printCollisions(t *Timetrace, records []*Record) { 281 out.Err("collides with these records :") 282 283 rows := make([][]string, len(records)) 284 285 for i, record := range records { 286 end := "still running" 287 if record.End != nil { 288 end = t.Formatter().TimeString(*record.End) 289 } 290 291 billable := "no" 292 293 if record.IsBillable { 294 billable = "yes" 295 } 296 297 rows[i] = make([]string, 6) 298 rows[i][0] = strconv.Itoa(i + 1) 299 rows[i][1] = t.Formatter().RecordKey(record) 300 rows[i][2] = record.Project.Key 301 rows[i][3] = t.Formatter().TimeString(record.Start) 302 rows[i][4] = end 303 rows[i][5] = billable 304 } 305 306 out.Table([]string{"#", "Key", "Project", "Start", "End", "Billable"}, rows, []string{}) 307 308 out.Warn(" start and end of the record should not overlap with others") 309} 310 311// RecordCollides checks if the time of a record collides 312// with other records of the same day and returns a bool 313func (t *Timetrace) RecordCollides(toCheck Record) (bool, error) { 314 allRecords, err := t.loadAllRecords(toCheck.Start) 315 if err != nil { 316 return false, err 317 } 318 319 if toCheck.Start.Day() != toCheck.End.Day() { 320 moreRecords, err := t.loadAllRecords(*toCheck.End) 321 if err != nil { 322 return false, err 323 } 324 for _, rec := range moreRecords { 325 allRecords = append(allRecords, rec) 326 } 327 } 328 329 collide, collidingRecords := collides(toCheck, allRecords) 330 if collide { 331 printCollisions(t, collidingRecords) 332 } 333 334 return collide, nil 335} 336 337func collides(toCheck Record, allRecords []*Record) (bool, []*Record) { 338 collide := false 339 collidingRecords := make([]*Record, 0) 340 for _, rec := range allRecords { 341 342 if rec.End != nil && rec.Start.Before(*toCheck.End) && rec.End.After(toCheck.Start) { 343 collidingRecords = append(collidingRecords, rec) 344 collide = true 345 } 346 347 if rec.End == nil && toCheck.End.After(rec.Start) { 348 collidingRecords = append(collidingRecords, rec) 349 collide = true 350 } 351 } 352 353 return collide, collidingRecords 354} 355 356// isBackFile checks if a given filename is a backup-file 357func isBakFile(filename string) bool { 358 return filepath.Ext(filename) == BakFileExt 359} 360