Skip to content

Commit 43fbc55

Browse files
committed
fix: timezone-agnostic generation
1 parent 60f1162 commit 43fbc55

File tree

4 files changed

+167
-9
lines changed

4 files changed

+167
-9
lines changed

internal/cli/generate.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ func runGenerate(
143143
}
144144
}
145145

146+
// Build a lookup from day date to first schedule window start
147+
dayStartTimes := buildDayStartTimes(daySchedules)
148+
146149
// Compute checkout attribution for each month in range
147150
entries, err := buildGenerateEntries(checkouts, daySchedules, from, to, now)
148151
if err != nil {
@@ -181,9 +184,13 @@ func runGenerate(
181184
generatedDays := make(map[string]bool)
182185
for _, ge := range entries {
183186
dt, _ := time.Parse("2006-01-02", ge.Date)
187+
startHour, startMin := 9, 0 // fallback
188+
if tod, ok := dayStartTimes[ge.Date]; ok {
189+
startHour, startMin = tod.Hour, tod.Minute
190+
}
184191
e := entry.Entry{
185192
ID: hashutil.GenerateID("generate"),
186-
Start: time.Date(dt.Year(), dt.Month(), dt.Day(), 9, 0, 0, 0, time.UTC),
193+
Start: time.Date(dt.Year(), dt.Month(), dt.Day(), startHour, startMin, 0, 0, time.UTC),
187194
Minutes: ge.Minutes,
188195
Message: ge.Branch,
189196
Task: ge.Branch,
@@ -389,6 +396,18 @@ func buildGenerateEntries(
389396
return result, nil
390397
}
391398

399+
// buildDayStartTimes returns a map from date string to the first schedule
400+
// window's start time for that day.
401+
func buildDayStartTimes(daySchedules []schedule.DaySchedule) map[string]schedule.TimeOfDay {
402+
m := make(map[string]schedule.TimeOfDay)
403+
for _, ds := range daySchedules {
404+
if len(ds.Windows) > 0 {
405+
m[ds.Date.Format("2006-01-02")] = ds.Windows[0].From
406+
}
407+
}
408+
return m
409+
}
410+
392411
// deleteGeneratedEntries deletes log entries with source="generate" that fall
393412
// on the given dates.
394413
func deleteGeneratedEntries(homeDir, slug string, dates []string) error {

internal/cli/generate_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/Flyrell/hourgit/internal/entry"
1111
"github.com/Flyrell/hourgit/internal/project"
12+
"github.com/Flyrell/hourgit/internal/schedule"
1213
"github.com/stretchr/testify/assert"
1314
"github.com/stretchr/testify/require"
1415
)
@@ -209,6 +210,105 @@ func TestGenerateByProjectFlag(t *testing.T) {
209210
assert.Contains(t, stdout.String(), "Generated")
210211
}
211212

213+
func TestGenerateEntryStartMatchesSchedule(t *testing.T) {
214+
homeDir, repoDir, proj := setupGenerateTest(t)
215+
pk := PromptKit{Confirm: AlwaysYes()}
216+
217+
// Set a schedule starting at midnight (0:00-8:00)
218+
err := project.SetSchedules(homeDir, proj.ID, []schedule.ScheduleEntry{
219+
{
220+
RRule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
221+
Ranges: []schedule.TimeRange{{From: "00:00", To: "08:00"}},
222+
},
223+
})
224+
require.NoError(t, err)
225+
226+
// Monday June 16, 2025 — checkout at midnight, now at 7:55
227+
mondayNow := func() time.Time {
228+
return time.Date(2025, 6, 16, 7, 55, 0, 0, time.UTC)
229+
}
230+
231+
co := entry.CheckoutEntry{
232+
ID: "co1",
233+
Timestamp: time.Date(2025, 6, 16, 0, 0, 0, 0, time.UTC),
234+
Previous: "main",
235+
Next: "feature-midnight",
236+
}
237+
require.NoError(t, entry.WriteCheckoutEntry(homeDir, proj.Slug, co))
238+
239+
stdout := new(bytes.Buffer)
240+
cmd := generateCmd
241+
cmd.SetOut(stdout)
242+
err = runGenerate(cmd, homeDir, repoDir, "", "", "", true, false, false, pk, mondayNow)
243+
require.NoError(t, err)
244+
245+
// Verify the generated entry starts at 00:00, not 09:00
246+
logs, err := entry.ReadAllEntries(homeDir, proj.Slug)
247+
require.NoError(t, err)
248+
require.GreaterOrEqual(t, len(logs), 1)
249+
250+
var found *entry.Entry
251+
for i, l := range logs {
252+
if l.Task == "feature-midnight" && l.Source == "generate" {
253+
found = &logs[i]
254+
break
255+
}
256+
}
257+
require.NotNil(t, found, "expected a generated entry with task=feature-midnight")
258+
assert.Equal(t, 0, found.Start.Hour(), "entry should start at hour 0")
259+
assert.Equal(t, 0, found.Start.Minute(), "entry should start at minute 0")
260+
assert.Equal(t, 475, found.Minutes, "expected 7h55m = 475 minutes")
261+
}
262+
263+
func TestGenerateNonUTCTimezone(t *testing.T) {
264+
homeDir, repoDir, proj := setupGenerateTest(t)
265+
pk := PromptKit{Confirm: AlwaysYes()}
266+
267+
// Set a schedule starting at midnight (0:00-8:00)
268+
err := project.SetSchedules(homeDir, proj.ID, []schedule.ScheduleEntry{
269+
{
270+
RRule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
271+
Ranges: []schedule.TimeRange{{From: "00:00", To: "08:00"}},
272+
},
273+
})
274+
require.NoError(t, err)
275+
276+
loc := time.FixedZone("UTC+1", 1*60*60)
277+
278+
// Monday June 16, 2025 — checkout at local midnight (23:00 UTC), now at 7:55 local (6:55 UTC)
279+
mondayNow := func() time.Time {
280+
return time.Date(2025, 6, 16, 7, 55, 0, 0, loc)
281+
}
282+
283+
co := entry.CheckoutEntry{
284+
ID: "co1",
285+
Timestamp: time.Date(2025, 6, 15, 23, 0, 0, 0, time.UTC), // = 00:00 UTC+1 on June 16
286+
Previous: "main",
287+
Next: "feature-tz",
288+
}
289+
require.NoError(t, entry.WriteCheckoutEntry(homeDir, proj.Slug, co))
290+
291+
stdout := new(bytes.Buffer)
292+
cmd := generateCmd
293+
cmd.SetOut(stdout)
294+
err = runGenerate(cmd, homeDir, repoDir, "", "", "", true, false, false, pk, mondayNow)
295+
require.NoError(t, err)
296+
297+
logs, err := entry.ReadAllEntries(homeDir, proj.Slug)
298+
require.NoError(t, err)
299+
300+
var found *entry.Entry
301+
for i, l := range logs {
302+
if l.Task == "feature-tz" && l.Source == "generate" {
303+
found = &logs[i]
304+
break
305+
}
306+
}
307+
require.NotNil(t, found, "expected a generated entry with task=feature-tz")
308+
// Should be 7h55m (local midnight to 7:55 local), not 6h55m
309+
assert.Equal(t, 475, found.Minutes, "expected 7h55m = 475 minutes")
310+
}
311+
212312
func TestGenerateRegisteredAsSubcommand(t *testing.T) {
213313
root := newRootCmd()
214314
names := make([]string, len(root.Commands()))

internal/timetrack/timetrack.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,21 +123,23 @@ func buildLogBucket(logs []entry.Entry, year int, month time.Month) (map[string]
123123
}
124124

125125
// buildCheckoutBucket computes per-branch, per-day minutes from checkout entries
126-
// clipped to schedule windows.
126+
// clipped to schedule windows. Schedule window times are interpreted in the
127+
// timezone of `now` (the user's local timezone).
127128
func buildCheckoutBucket(
128129
checkouts []entry.CheckoutEntry,
129130
year int, month time.Month, daysInMonth int,
130131
scheduleWindows map[int][]schedule.TimeWindow,
131132
now time.Time,
132133
) map[string]map[int]int {
134+
loc := now.Location()
133135
sorted := make([]entry.CheckoutEntry, len(checkouts))
134136
copy(sorted, checkouts)
135137
sort.Slice(sorted, func(i, j int) bool {
136138
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
137139
})
138140

139-
monthStart := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
140-
monthEnd := time.Date(year, month, daysInMonth, 23, 59, 59, 0, time.UTC)
141+
monthStart := time.Date(year, month, 1, 0, 0, 0, 0, loc)
142+
monthEnd := time.Date(year, month, daysInMonth, 23, 59, 59, 0, loc)
141143

142144
var pairs []checkoutRange
143145
lastBeforeIdx := -1
@@ -188,7 +190,7 @@ func buildCheckoutBucket(
188190
if !ok {
189191
continue
190192
}
191-
mins := overlapMinutes(p.from, p.to, year, month, day, windows)
193+
mins := overlapMinutes(p.from, p.to, year, month, day, windows, loc)
192194
if mins > 0 {
193195
checkoutBucket[p.branch][day] += mins
194196
}
@@ -298,12 +300,13 @@ func windowMinutes(w schedule.TimeWindow) int {
298300
}
299301

300302
// overlapMinutes computes how many minutes of the checkout range [from, to)
301-
// overlap with the given schedule windows on a specific day.
302-
func overlapMinutes(from, to time.Time, year int, month time.Month, day int, windows []schedule.TimeWindow) int {
303+
// overlap with the given schedule windows on a specific day. Schedule window
304+
// times are interpreted in the given location (the user's local timezone).
305+
func overlapMinutes(from, to time.Time, year int, month time.Month, day int, windows []schedule.TimeWindow, loc *time.Location) int {
303306
total := 0
304307
for _, w := range windows {
305-
wStart := time.Date(year, month, day, w.From.Hour, w.From.Minute, 0, 0, time.UTC)
306-
wEnd := time.Date(year, month, day, w.To.Hour, w.To.Minute, 0, 0, time.UTC)
308+
wStart := time.Date(year, month, day, w.From.Hour, w.From.Minute, 0, 0, loc)
309+
wEnd := time.Date(year, month, day, w.To.Hour, w.To.Minute, 0, 0, loc)
307310

308311
// Overlap: max(from, wStart) to min(to, wEnd)
309312
overlapStart := from

internal/timetrack/timetrack_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,42 @@ func TestBuildReport_LastCheckoutCappedAtNow(t *testing.T) {
211211
assert.Equal(t, 240, report.Rows[0].TotalMinutes)
212212
}
213213

214+
func TestBuildReport_ScheduleWindowsInterpretedInLocalTimezone(t *testing.T) {
215+
year, month := 2025, time.January
216+
loc := time.FixedZone("UTC+1", 1*60*60)
217+
218+
// Schedule: 0:00-8:00 (user means local midnight to 8am local)
219+
days := []schedule.DaySchedule{
220+
{
221+
Date: time.Date(year, month, 2, 0, 0, 0, 0, time.UTC),
222+
Windows: []schedule.TimeWindow{
223+
{
224+
From: schedule.TimeOfDay{Hour: 0, Minute: 0},
225+
To: schedule.TimeOfDay{Hour: 8, Minute: 0},
226+
},
227+
},
228+
},
229+
}
230+
231+
// Checkout at local midnight (00:00 UTC+1 = 23:00 UTC day before)
232+
checkouts := []entry.CheckoutEntry{
233+
{ID: "c1", Timestamp: time.Date(2025, 1, 1, 23, 0, 0, 0, time.UTC), Previous: "main", Next: "feature-x"},
234+
}
235+
236+
// "now" is 7:55 local (UTC+1) = 6:55 UTC.
237+
// With UTC-interpreted windows: overlap of [23:00 UTC, 6:55 UTC] with
238+
// [00:00 UTC, 08:00 UTC] = 6h55m (wrong).
239+
// With local-interpreted windows: overlap of [23:00 UTC, 6:55 UTC] with
240+
// [23:00 UTC, 07:00 UTC] (= 00:00-08:00 UTC+1) = 7h55m (correct).
241+
now := time.Date(2025, 1, 2, 7, 55, 0, 0, loc) // = 6:55 UTC
242+
243+
report := BuildReport(checkouts, nil, days, year, month, now, nil)
244+
245+
assert.Equal(t, 1, len(report.Rows))
246+
assert.Equal(t, "feature-x", report.Rows[0].Name)
247+
assert.Equal(t, 475, report.Rows[0].Days[2]) // 7h55m = 475 min
248+
}
249+
214250
func findRow(report ReportData, name string) *TaskRow {
215251
for i := range report.Rows {
216252
if report.Rows[i].Name == name {

0 commit comments

Comments
 (0)