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