1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
|
package worktime
import (
"errors"
"fmt"
"sort"
"strings"
"time"
)
const (
actionLogin = "login"
actionLogout = "logout"
actionAdd = "add"
dayOffHours = 8
)
var (
// ErrAlreadyLoggedIn indicates that a category already has an open login entry.
ErrAlreadyLoggedIn = errors.New("already logged in")
// ErrNotLoggedIn indicates that a category has no active login entry.
ErrNotLoggedIn = errors.New("not logged in")
)
// Login creates a login entry after validating the category is not already logged in.
func Login(dbDir, hostname, category string, at time.Time, descr string) (Entry, error) {
host, err := normalizeHostname(hostname)
if err != nil {
return Entry{}, err
}
cat := normalizeCategory(category)
loggedIn, err := isLoggedIn(dbDir, cat)
if err != nil {
return Entry{}, err
}
if loggedIn {
return Entry{}, fmt.Errorf("%w for %q", ErrAlreadyLoggedIn, cat)
}
entry := newEntry(actionLogin, host, cat, at, 0, descr)
return appendHostEntry(dbDir, host, entry)
}
// Logout creates a logout entry after validating the category is currently logged in.
func Logout(dbDir, hostname, category string, at time.Time, descr string) (Entry, error) {
host, err := normalizeHostname(hostname)
if err != nil {
return Entry{}, err
}
cat := normalizeCategory(category)
loggedIn, err := isLoggedIn(dbDir, cat)
if err != nil {
return Entry{}, err
}
if !loggedIn {
return Entry{}, fmt.Errorf("%w for %q", ErrNotLoggedIn, cat)
}
entry := newEntry(actionLogout, host, cat, at, 0, descr)
return appendHostEntry(dbDir, host, entry)
}
// Add creates an add entry with a positive duration.
func Add(dbDir, hostname, category string, duration time.Duration, at time.Time, descr string) (Entry, error) {
host, err := normalizeHostname(hostname)
if err != nil {
return Entry{}, err
}
if duration <= 0 {
return Entry{}, errors.New("duration must be positive")
}
cat := normalizeCategory(category)
entry := newEntry(actionAdd, host, cat, at, durationToSeconds(duration), descr)
return appendHostEntry(dbDir, host, entry)
}
// AddDayOff creates an 8-hour day-off entry for the provided day.
func AddDayOff(dbDir, hostname string, day time.Time, descr string) (Entry, error) {
return Add(dbDir, hostname, "off", time.Duration(dayOffHours)*time.Hour, startOfDay(day), descr)
}
// Sub creates an add entry with a negative duration value.
func Sub(dbDir, hostname, category string, duration time.Duration, at time.Time, descr string) (Entry, error) {
host, err := normalizeHostname(hostname)
if err != nil {
return Entry{}, err
}
if duration <= 0 {
return Entry{}, errors.New("duration must be positive")
}
cat := normalizeCategory(category)
entry := newEntry(actionAdd, host, cat, at, -durationToSeconds(duration), descr)
return appendHostEntry(dbDir, host, entry)
}
// UseBuffer transfers duration from selfdevelopment to work.
func UseBuffer(dbDir, hostname string, duration time.Duration, at time.Time, descr string) ([]Entry, error) {
if duration <= 0 {
return nil, errors.New("duration must be positive")
}
removed, err := Sub(dbDir, hostname, "selfdevelopment", duration, at, descr)
if err != nil {
return nil, err
}
added, err := Add(dbDir, hostname, "work", duration, at, descr)
if err != nil {
return nil, err
}
return []Entry{removed, added}, nil
}
// EditEntry replaces an entry by index in the host database after validation.
func EditEntry(dbDir, hostname string, index int, replacement Entry) (Entry, error) {
host, err := normalizeHostname(hostname)
if err != nil {
return Entry{}, err
}
db, err := LoadHost(dbDir, host)
if err != nil {
return Entry{}, err
}
entries := db.Entries[host]
if index < 0 || index >= len(entries) {
return Entry{}, fmt.Errorf("entry index %d out of range", index)
}
normalized, err := normalizeEditedEntry(replacement, host)
if err != nil {
return Entry{}, err
}
entries[index] = normalized
db.Entries[host] = entries
if err := SaveHost(dbDir, host, db); err != nil {
return Entry{}, err
}
return normalized, nil
}
// NormalizeEditedEntry validates and normalizes an edited entry for a host without persisting it.
func NormalizeEditedEntry(entry Entry, hostname string) (Entry, error) {
host, err := normalizeHostname(hostname)
if err != nil {
return Entry{}, err
}
return normalizeEditedEntry(entry, host)
}
// DeleteEntry removes an entry by index from the host database.
func DeleteEntry(dbDir, hostname string, index int) (Entry, error) {
host, err := normalizeHostname(hostname)
if err != nil {
return Entry{}, err
}
db, err := LoadHost(dbDir, host)
if err != nil {
return Entry{}, err
}
entries := db.Entries[host]
if index < 0 || index >= len(entries) {
return Entry{}, fmt.Errorf("entry index %d out of range", index)
}
removed := entries[index]
db.Entries[host] = append(entries[:index], entries[index+1:]...)
if err := SaveHost(dbDir, host, db); err != nil {
return Entry{}, err
}
return removed, nil
}
func appendHostEntry(dbDir, host string, entry Entry) (Entry, error) {
db, err := LoadHost(dbDir, host)
if err != nil {
return Entry{}, err
}
db.Entries[host] = append(db.Entries[host], entry)
if err := SaveHost(dbDir, host, db); err != nil {
return Entry{}, err
}
return entry, nil
}
func isLoggedIn(dbDir, category string) (bool, error) {
entries, err := LoadAll(dbDir)
if err != nil {
return false, err
}
for _, active := range ActiveCategories(entries) {
if active == category {
return true, nil
}
}
return false, nil
}
// ActiveCategories returns sorted categories that are currently logged in.
func ActiveCategories(entries []Entry) []string {
status := map[string]bool{}
for _, entry := range entries {
category := normalizeCategory(entry.What)
switch strings.ToLower(strings.TrimSpace(entry.Action)) {
case actionLogin:
status[category] = true
case actionLogout:
status[category] = false
}
}
active := make([]string, 0, len(status))
for category, loggedIn := range status {
if loggedIn {
active = append(active, category)
}
}
sort.Strings(active)
return active
}
func normalizeCategory(category string) string {
cat := strings.TrimSpace(category)
if cat == "" {
return "work"
}
return cat
}
func durationToSeconds(duration time.Duration) int64 {
return int64(duration / time.Second)
}
func startOfDay(value time.Time) time.Time {
effective := effectiveTime(value)
year, month, day := effective.Date()
return time.Date(year, month, day, 0, 0, 0, 0, effective.Location())
}
func effectiveTime(at time.Time) time.Time {
if at.IsZero() {
return time.Now()
}
return at
}
func newEntry(action, host, category string, at time.Time, value int64, descr string) Entry {
eventTime := effectiveTime(at)
entry := Entry{
Action: action,
What: category,
Epoch: eventTime.Unix(),
Source: host,
Human: eventTime.Format("Mon 02.01.2006 15:04:05"),
Value: value,
}
if trimmedDescr := strings.TrimSpace(descr); trimmedDescr != "" {
entry.Descr = trimmedDescr
}
return entry
}
func normalizeEditedEntry(entry Entry, host string) (Entry, error) {
action := strings.ToLower(strings.TrimSpace(entry.Action))
switch action {
case actionLogin, actionLogout, actionAdd:
default:
return Entry{}, fmt.Errorf("unsupported action %q", entry.Action)
}
if entry.Epoch <= 0 {
return Entry{}, errors.New("epoch must be greater than zero")
}
entry.Action = action
entry.What = normalizeCategory(entry.What)
entry.Source = strings.TrimSpace(entry.Source)
if entry.Source == "" {
entry.Source = host
}
if entry.Source != host {
return Entry{}, fmt.Errorf("entry source %q does not match host %q", entry.Source, host)
}
if action != actionAdd {
entry.Value = 0
}
if strings.TrimSpace(entry.Human) == "" {
entry.Human = time.Unix(entry.Epoch, 0).Format("Mon 02.01.2006 15:04:05")
}
entry.Descr = strings.TrimSpace(entry.Descr)
return entry, nil
}
|