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
|
package main
import (
"database/sql"
"fmt"
"time"
)
// timestampToDate formats a unix timestamp as "YYYY-MM-DD".
func timestampToDate(ts int64) string {
t := time.Unix(ts, 0)
return t.Format("2006-01-02")
}
// timestampToTime formats a unix timestamp as "HH:MM".
func timestampToTime(ts int64) string {
t := time.Unix(ts, 0)
return t.Format("15:04")
}
// formatEndTime formats an end time, returning "---" if NULL (in progress).
// If the end date differs from the start date (cross-midnight entries),
// shows hours elapsed from start time instead of clock time (e.g., 27:00).
func formatEndTime(startTime int64, endTime sql.NullInt64) string {
if !endTime.Valid {
return "---"
}
// Check if dates differ
startDate := timestampToDate(startTime)
endDate := timestampToDate(endTime.Int64)
if startDate != endDate {
// Cross-midnight entry - show hours elapsed from start time of day
startT := time.Unix(startTime, 0)
durationSeconds := endTime.Int64 - startTime
// Calculate end as hours from midnight + duration
startHour := startT.Hour()
startMinute := startT.Minute()
durationHours := int(durationSeconds / 3600)
durationMinutes := int((durationSeconds % 3600) / 60)
totalMinutes := startHour*60 + startMinute + durationHours*60 + durationMinutes
endHour := totalMinutes / 60
endMinute := totalMinutes % 60
return fmt.Sprintf("%02d:%02d", endHour, endMinute)
}
// Same day - show clock time
return timestampToTime(endTime.Int64)
}
// formatDuration calculates and formats the duration between start and end times.
// If end time is NULL, calculates duration from start to now (in-progress entry).
func formatDuration(startTime int64, endTime sql.NullInt64) string {
var durationSeconds int64
if endTime.Valid {
durationSeconds = endTime.Int64 - startTime
} else {
// In progress - calculate from start to now
durationSeconds = time.Now().Unix() - startTime
}
hours := durationSeconds / 3600
minutes := (durationSeconds % 3600) / 60
return fmt.Sprintf("%02d:%02d", hours, minutes)
}
// calculateTotalHours calculates the total hours from all time entries
func calculateTotalHours(entries []TimeEntry) float64 {
totalSeconds := int64(0)
for _, entry := range entries {
var durationSeconds int64
if entry.EndTime.Valid {
durationSeconds = entry.EndTime.Int64 - entry.StartTime
} else {
// In progress - calculate from start to now
durationSeconds = time.Now().Unix() - entry.StartTime
}
totalSeconds += durationSeconds
}
return float64(totalSeconds) / 3600.0 // Convert to hours
}
// calculateUninvoicedHours calculates the total hours from uninvoiced time entries
func calculateUninvoicedHours(entries []TimeEntry) float64 {
totalSeconds := int64(0)
for _, entry := range entries {
// Skip invoiced entries
if entry.InvoiceID.Valid {
continue
}
var durationSeconds int64
if entry.EndTime.Valid {
durationSeconds = entry.EndTime.Int64 - entry.StartTime
} else {
// In progress - calculate from start to now
durationSeconds = time.Now().Unix() - entry.StartTime
}
totalSeconds += durationSeconds
}
return float64(totalSeconds) / 3600.0 // Convert to hours
}
// findMostRecentCompletedEntry finds the most recently completed entry for a client
// Returns nil if no completed entries exist or if the most recent one is > 15min old
func findMostRecentCompletedEntry(entries []TimeEntry) *TimeEntry {
var mostRecent *TimeEntry
var mostRecentEndTime int64 = 0
for i := range entries {
entry := &entries[i]
if entry.EndTime.Valid && entry.EndTime.Int64 > mostRecentEndTime {
mostRecentEndTime = entry.EndTime.Int64
mostRecent = entry
}
}
if mostRecent == nil {
return nil
}
// Check if it's within 15 minutes
minutesSinceEnd := (time.Now().Unix() - mostRecent.EndTime.Int64) / 60
if minutesSinceEnd >= 15 {
return nil
}
return mostRecent
}
// parseDateTime parses a date and time string into a unix timestamp with optional timezone.
// The timezone parameter can be empty (defaults to local time) or a timezone name like "UTC", "Europe/Berlin", etc.
func parseDateTime(date, timeStr, timezone string) (int64, error) {
layout := "2006-01-02 15:04"
// Determine which timezone to use
var loc *time.Location
var err error
if timezone == "" {
// Default to local timezone
loc = time.Local
} else {
// Load the specified timezone
loc, err = time.LoadLocation(timezone)
if err != nil {
return 0, fmt.Errorf("invalid timezone '%s': %w", timezone, err)
}
}
t, err := time.ParseInLocation(layout, date+" "+timeStr, loc)
if err != nil {
return 0, fmt.Errorf("failed to parse date/time '%s %s' in timezone '%s': %w", date, timeStr, timezone, err)
}
return t.Unix(), nil
}
// SnapToPrevious15Min rounds a time DOWN to the nearest 15-minute mark.
// Examples:
// 14:37 → 14:30
// 14:00 → 14:00 (already on 15min mark)
// 14:14 → 14:00
// 14:45 → 14:45 (already on 15min mark)
func SnapToPrevious15Min(t time.Time) time.Time {
// Get minutes since midnight
totalMinutes := t.Hour()*60 + t.Minute()
// Round down to nearest 15 minutes
snappedMinutes := (totalMinutes / 15) * 15
// Create new time with snapped minutes, zeroing out seconds and nanoseconds
return time.Date(t.Year(), t.Month(), t.Day(), snappedMinutes/60, snappedMinutes%60, 0, 0, t.Location())
}
// SnapToNext15Min rounds a time UP to the nearest 15-minute mark.
// Examples:
// 14:37 → 14:45
// 14:00 → 14:00 (already on 15min mark)
// 14:01 → 14:15
// 14:45 → 14:45 (already on 15min mark)
func SnapToNext15Min(t time.Time) time.Time {
// Get minutes since midnight
totalMinutes := t.Hour()*60 + t.Minute()
// Round up to nearest 15 minutes
snappedMinutes := ((totalMinutes + 14) / 15) * 15
// Create new time with snapped minutes, zeroing out seconds and nanoseconds
return time.Date(t.Year(), t.Month(), t.Day(), snappedMinutes/60, snappedMinutes%60, 0, 0, t.Location())
}
|