Skip to content

Commit aad6305

Browse files
committed
fix(util): resolve timezone issue in format_time function
The format_time function was always returning full timestamps due to a timezone offset bug. The original code mixed UTC time construction (os.time with date tables) and local time parsing (os.date '*t'), creating inconsistent comparisons.
1 parent b3dc11c commit aad6305

File tree

2 files changed

+163
-8
lines changed

2 files changed

+163
-8
lines changed

lua/opencode/util.lua

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,7 @@ function M.format_time(timestamp)
133133
timestamp = math.floor(timestamp / 1000)
134134
end
135135

136-
local local_t = os.date('*t') --[[@as std.osdateparam]]
137-
local today_start = os.time({ year = local_t.year, month = local_t.month, day = local_t.day })
138-
139-
if timestamp >= today_start then
136+
if os.date('%Y-%m-%d', timestamp) == os.date('%Y-%m-%d') then
140137
return os.date('%I:%M %p', timestamp) --[[@as string]]
141138
end
142139

tests/unit/util_spec.lua

Lines changed: 162 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,17 @@ describe('util.parse_run_args', function()
5353
end)
5454

5555
it('parses multiple prefixes in order', function()
56-
local opts, prompt = util.parse_run_args({ 'agent=plan', 'model=openai/gpt-4', 'context=current_file.enabled=false', 'prompt', 'here' })
56+
local opts, prompt = util.parse_run_args({
57+
'agent=plan',
58+
'model=openai/gpt-4',
59+
'context=current_file.enabled=false',
60+
'prompt',
61+
'here',
62+
})
5763
assert.are.same({
5864
agent = 'plan',
5965
model = 'openai/gpt-4',
60-
context = { current_file = { enabled = false } }
66+
context = { current_file = { enabled = false } },
6167
}, opts)
6268
assert.equals('prompt here', prompt)
6369
end)
@@ -67,8 +73,8 @@ describe('util.parse_run_args', function()
6773
assert.are.same({
6874
context = {
6975
current_file = { enabled = false },
70-
selection = { enabled = true }
71-
}
76+
selection = { enabled = true },
77+
},
7278
}, opts)
7379
assert.equals('test', prompt)
7480
end)
@@ -91,3 +97,155 @@ describe('util.parse_run_args', function()
9197
assert.equals('some prompt model=openai/gpt-4', prompt)
9298
end)
9399
end)
100+
101+
describe('util.format_time', function()
102+
local function make_timestamp(year, month, day, hour, min, sec)
103+
return os.time({ year = year, month = month, day = day, hour = hour or 0, min = min or 0, sec = sec or 0 })
104+
end
105+
106+
local today = os.date('*t')
107+
local today_morning = make_timestamp(today.year, today.month, today.day, 8, 30, 0)
108+
local today_afternoon = make_timestamp(today.year, today.month, today.day, 15, 45, 30)
109+
local today_evening = make_timestamp(today.year, today.month, today.day, 23, 59, 59)
110+
111+
local yesterday = os.time() - 86400 -- 24 hours ago
112+
local last_week = os.time() - (7 * 86400) -- 7 days ago
113+
local next_year = make_timestamp(today.year + 1, 6, 15, 12, 0, 0)
114+
115+
describe('today timestamps', function()
116+
it('formats morning time correctly', function()
117+
local result = util.format_time(today_morning)
118+
assert.matches('^%d%d?:%d%d [AP]M$', result)
119+
assert.is_nil(result:match('%d%d%d%d'))
120+
end)
121+
122+
it('formats afternoon time correctly', function()
123+
local result = util.format_time(today_afternoon)
124+
assert.matches('^%d%d?:%d%d [AP]M$', result)
125+
assert.is_nil(result:match('%d%d%d%d'))
126+
end)
127+
128+
it('formats late evening time correctly', function()
129+
local result = util.format_time(today_evening)
130+
assert.matches('^%d%d?:%d%d [AP]M$', result)
131+
assert.is_nil(result:match('%d%d%d%d'))
132+
end)
133+
134+
it('formats current time as time-only', function()
135+
local current_time = os.time()
136+
local result = util.format_time(current_time)
137+
assert.matches('^%d%d?:%d%d [AP]M$', result)
138+
assert.is_nil(result:match('%d%d%d%d'))
139+
end)
140+
end)
141+
142+
describe('other day timestamps', function()
143+
it('formats yesterday with full date', function()
144+
local result = util.format_time(yesterday)
145+
assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result)
146+
assert.matches('%d%d%d%d', result)
147+
end)
148+
149+
it('formats last week with full date', function()
150+
local result = util.format_time(last_week)
151+
assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result)
152+
assert.matches('%d%d%d%d', result)
153+
end)
154+
155+
it('formats future date with full date', function()
156+
local result = util.format_time(next_year)
157+
assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result)
158+
assert.matches('%d%d%d%d', result)
159+
end)
160+
end)
161+
162+
describe('millisecond timestamp conversion', function()
163+
it('converts millisecond timestamps to seconds', function()
164+
local seconds_timestamp = os.time()
165+
local milliseconds_timestamp = seconds_timestamp * 1000
166+
167+
local seconds_result = util.format_time(seconds_timestamp)
168+
local milliseconds_result = util.format_time(milliseconds_timestamp)
169+
170+
assert.equals(seconds_result, milliseconds_result)
171+
end)
172+
173+
it('handles large millisecond timestamps correctly', function()
174+
local ms_timestamp = 1762350000000 -- ~November 2025 in milliseconds
175+
local result = util.format_time(ms_timestamp)
176+
177+
assert.is_not_nil(result)
178+
assert.is_string(result)
179+
180+
local is_time_only = result:match('^%d%d?:%d%d [AP]M$')
181+
local is_full_date = result:match('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$')
182+
assert.is_true(is_time_only ~= nil or is_full_date ~= nil)
183+
end)
184+
185+
it('does not convert regular second timestamps', function()
186+
local small_timestamp = 1000000000 -- Year 2001, definitely in seconds
187+
local result = util.format_time(small_timestamp)
188+
189+
-- Should format without error
190+
assert.is_not_nil(result)
191+
assert.is_string(result)
192+
end)
193+
end)
194+
195+
describe('edge cases', function()
196+
it('handles midnight correctly', function()
197+
local midnight = make_timestamp(today.year, today.month, today.day, 0, 0, 0)
198+
local result = util.format_time(midnight)
199+
200+
if os.date('%Y-%m-%d', midnight) == os.date('%Y-%m-%d') then
201+
assert.matches('^%d%d?:%d%d [AP]M$', result)
202+
assert.matches('12:00 AM', result) -- Midnight should be 12:00 AM
203+
else
204+
assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result)
205+
end
206+
end)
207+
208+
it('handles noon correctly', function()
209+
local noon = make_timestamp(today.year, today.month, today.day, 12, 0, 0)
210+
local result = util.format_time(noon)
211+
212+
assert.matches('^%d%d?:%d%d [AP]M$', result)
213+
assert.matches('12:00 PM', result) -- Noon should be 12:00 PM
214+
end)
215+
216+
it('handles date boundary transitions', function()
217+
local late_today = make_timestamp(today.year, today.month, today.day, 23, 59, 0)
218+
local early_tomorrow = late_today + 120 -- 2 minutes later (next day)
219+
220+
local late_result = util.format_time(late_today)
221+
local early_result = util.format_time(early_tomorrow)
222+
223+
-- Late today should be time-only
224+
assert.matches('^%d%d?:%d%d [AP]M$', late_result)
225+
226+
-- Early tomorrow behavior depends on whether it's actually tomorrow
227+
if os.date('%Y-%m-%d', early_tomorrow) == os.date('%Y-%m-%d') then
228+
-- Still today
229+
assert.matches('^%d%d?:%d%d [AP]M$', early_result)
230+
else
231+
-- Actually tomorrow
232+
assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', early_result)
233+
end
234+
end)
235+
end)
236+
237+
describe('timezone consistency', function()
238+
it('uses consistent timezone for date comparison', function()
239+
local now = os.time()
240+
241+
-- Both should use the same local timezone
242+
local timestamp_date = os.date('%Y-%m-%d', now)
243+
local current_date = os.date('%Y-%m-%d')
244+
245+
assert.equals(timestamp_date, current_date)
246+
247+
local result = util.format_time(now)
248+
assert.matches('^%d%d?:%d%d [AP]M$', result)
249+
end)
250+
end)
251+
end)

0 commit comments

Comments
 (0)