You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
hey event add reads clock times in the HEY account's time zone since #498, and so does HEY's web app. The TUI's event form did not: it opened on Local, read the typed time on the machine's clock, and sent it converted to UTC with no zone. The same 10:00 typed in the TUI, the CLI and the web could land in three different places, and a TUI event was always saved zoneless.
What changes
A new event opens on the account's zone. The Starts and Ends rows name it (e.g. America/Indiana/Indianapolis), and the write sends it as a named zone, so HEY places the wall clock itself — no local conversion. The default start is the first whole hour on the account's clock (date and hour both taken from that clock) that HEY places at or after the moment the form opens. That keeps it on the day in view as the grid draws it, even when the account's clock is already on another date: at 17:30 in Los Angeles with a Madrid account it offers 03:00 on the next day in Madrid, which is 18:00 the same day in Los Angeles. On the clock's own day that is the next hour HEY places at or after now: at the second 01:00 of a fall-back night it is 02:00, not the 01:00 HEY would place an hour in the past. A view pinned to a day that has since become today reads the clock again, so it doesn't offer an hour that has already passed. The default end is the first time at least an hour later that HEY places where it is shown, found by searching clock times minute by minute. A fall-back night gives 01:00–02:00 in New York and at Lord Howe, and Monrovia's 1972 half-minute offset gives 23:00–00:45.
Where the zone comes from. The Calendar already reads the identity every time it is entered (fetchIdentity, for the week start and clock format). That read now carries time_zone too, and the form is handed it. No new request and no process-wide cache. The zone is cleared when the read starts, so an earlier visit's answer never stands in for it. A new-event form opened before the answer lands opens on Local and switches to the account's zone when the answer arrives, unless the reader has already changed the day, a time or a zone.
Fallback is Local, never a refusal. An account with no zone, a failed identity read, or a zone HEY would not look up opens the form on Local, as before. Unlike the CLI, which refuses, the form shows which zone it is using, so the reader can see it and change it.
Local and the zone list stay. Choosing Local still sends the time as UTC with no zone.
Edits keep the event's own terms, as hey event edit does: a zoned event keeps its zone, and a timed event saved without one stays on Local and goes back zoneless. The CLI reads a typed time on a zoneless event in the account's zone; the form shows those times on the clock the calendar grid draws them on, so the reader types on the clock they are reading. An all-day event given a time takes the account's zone, as the CLI gives it.
Shared with the CLI. A new internal/timezone package holds what the CLI already did and the form now needs. timezone.Load (formerly loadEventZone) validates zone names the way HEY looks them up, with the embedded zone database. timezone.WallClock (formerly wallClockOn) places a clock time the way HEY places one when the clocks skip or repeat it. The form uses Load for its zone check and WallClock for its order check and the default start. So 02:30 to 03:00 on the morning New York springs forward is refused, since HEY moves 02:30 on to 03:30, and a form opened at 01:41 that morning offers 03:00.
A save never moves an event silently. Every end is checked against where HEY will place what it is sent (timezone.Placed). An end whose date, time and zone still show what they opened with keeps the instant the event already has. That holds however the reader got back to those values: reselecting the same zone, typing and undoing, or stepping the date and back. Only a real change is read the way HEY reads a clock time. A kept end HEY can't represent is refused, with how far it would move: one at the second 01:30 of a fall-back night (HEY takes the first), or one with seconds. The refusal names every end that would move, then says to choose other times, or press ctrl+s again with nothing pressed in between to save them where HEY reads them. The same check catches a Local end written on a zoned start's clock that would land on the other moment. The tracked-time form keeps unchanged ends the same way.
Local is read the way HEY reads it. A time typed on Local goes through WallClock as well, so 02:30 on New York's spring-forward morning is 03:30, as in the CLI.
The reader's choices outrank a late identity read. Editing a date or time, choosing a zone (even the one already shown, such as Local), or switching All day all count as answers, so a late identity read never overrides them. A new form's days stay as the reader saw them. Pressing ctrl+s also counts, whether the save goes out, fails or is refused, so a form whose write is in flight always shows what it is writing. The same read gives an untouched all-day edit the account's zone for when it is made timed.
Zone names are sanitized on screen, both on the form and in the open list. The sink manifest now lists the picker's zone fields.
Two small fixes this exposed
One end on Local beside a zoned one. HEY keeps a zone for both ends or neither, and the SDK names the one it is given for both. So a Local end was sent as a UTC clock time that HEY then read in the other end's zone. That end is now written on the other end's clock: the same instant, on the clock HEY will read it on.
Half-hour zones. The default start used a whole UTC hour, which is half past in Kolkata or Chatham. It is now a whole hour on the zone's own clock. As a side effect, TestNewEventFormOpensOnTheDayInView and TestCreateEventPostsTheForm now pass under TZ=Asia/Kolkata and Pacific/Chatham.
Not changed
The time tracking form uses the same picker and still opens on Local.
Aligns TUI event creation with the HEY account’s time zone and centralizes zone validation.
Changes:
Uses the account zone for new TUI events while preserving edit semantics.
Shares strict IANA-zone validation between CLI and TUI.
Adds timezone behavior tests and documentation.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
File
Description
internal/tui/event_form.go
Applies account zones and serializes mixed-zone endpoints.
The reason will be displayed to describe this comment to others. Learn more.
Approved. Cursor Security Agent completed with no findings that need human review; Cursor Bugbot was not running on this PR. No reviewers were assigned.
Sent by Cursor Approval Agent: Pull Request Approver
Preserve one-hour duration across daylight-saving fall-back overlaps
internal/tui/event_form.go:203
The one-hour default does not survive a fall-back overlap. At 00:41 before New York's 2026 fall-back, newEventStart returns the first 01:00; adding one elapsed hour produces the second 01:00, but both pickers display and submit 01:00 America/New_York, and HEY resolves both to the first occurrence, creating a zero-duration event. Choose an end clock value that round-trips through timezone.WallClock (or move the default start) and add a fall-back test.
This issue also appears in the following locations of the same file:
On the review of f21e097, the "previously missed" finding (a one-hour default across a fall-back overlap) is fixed in a8de754. At 00:41 on the night New York falls back, the start is the first 01:00, and one elapsed hour later is the second 01:00. The form showed and sent that as the same clock, and HEY places both at the first, which made a zero-length event. eventForm.offerAnHour now moves a new event's end on an hour at a time until it reads as after the start, which there is 02:00. This applies both when the form opens and when a late identity read changes its zone. Covered by TestNewEventFormEndsAfterItStartsAcrossAFallBack. Edits still show the event's own end.
loadEventZone lived in internal/cmd, where the TUI cannot reach it, and the
TUI's event form checked a zone with time.LoadLocation alone, which answers
names HEY cannot look up. timezone.Load is the same check, moved so both can
use it, and it carries the embedded zone database with it.
The event form opened on Local: it read a typed time on the machine's clock
and sent it as UTC with no zone, so a TUI event was saved zoneless while
hey event add and HEY's web app read the same typed time in the account's
zone.
A new event now opens on the account's zone, named on the Starts and Ends
rows and sent with the write. The zone comes from the identity the Calendar
already reads each time it opens, so a zone changed on the web is picked up
the next time it is entered. An account with no zone, a failed read, or a
name HEY would not look up leaves the form on Local rather than refusing to
open it. Local and every other zone stay in the list.
An edit keeps the event's own terms: a zoned event keeps its zone, and a
timed event saved without one stays on Local and goes back zoneless. An
all-day event given a time takes the account's zone, as hey event edit
gives it.
The next whole hour a new event starts on is the account clock's, on the
day in view, and it is a whole hour of that clock: in a half-hour zone it
used to be a whole UTC hour, half past on the reader's clock.
HEY keeps a zone for both ends or neither, and names the one given for
both, so an end moved back to Local beside a zoned one is now written on
the other end's clock rather than sent as a UTC time HEY would read in
that zone. Zones on the form are checked with timezone.Load, the rules
hey event --time-zone uses.
wallClockOn is how HEY resolves a clock time the clocks skip or repeat, and
the TUI's event form needs the same answer to order two moments and to
offer a default start. It moves to internal/timezone as WallClock, unchanged.
…zone
The form ordered two moments with time.ParseInLocation, which puts 02:30 on
the morning New York springs forward at 01:30, where HEY puts it at 03:30:
02:30 to 03:00 passed the form and was refused by HEY. A moment in a named
zone is now placed with timezone.WallClock. The default start went the same
way, and at 01:41 that morning offered an 02:00 Go put back at 01:00; it is
now 03:00.
The account's zone came from whatever the last identity read said until the
current one answered, so a form opened in that window used an earlier
visit's zone, or kept it after a failed read. The zone is now cleared when
the Calendar starts its read, and a new-event form opened before the answer
takes the zone when it lands, as long as the reader has not changed the day,
a time or a zone.
The zone picker lists the zones in the machine's zone database, and a shortlist where that database cannot be listed, so docs/tui.md no longer says it offers every zone.
Each visit to the Calendar starts an identity read, and an earlier visit's
answer could land after a later one's and put its zone back. The answer now
carries which visit's read it is, and anything but the latest is dropped.
An hour on from the first 01:00 of the night New York falls back is the
second 01:00, which the form showed and sent as the same clock as the start,
and HEY places both at the first: a zero-length event. A new event's end now
moves on an hour at a time until it reads as after the start, which there is
02:00.
…es it
timezone.Placed is the instant HEY stores for a date and clock sent with a
zone, or read as UTC without one; timezone.Representable is whether an
instant comes back as itself when sent as a clock time in a zone. The CLI's
clockZone.instant is now Placed, and its movedBy moves across as
timezone.MovedBy, so the TUI's event form can refuse the same moves the CLI
refuses and say them the same way.
…nitize zone names
The date-time picker read a clock time on Local with time.ParseInLocation,
which puts 02:30 on the morning New York springs forward at 01:30, so a time
typed on Local was saved an hour from where HEY and hey event put it. Local
is now read with timezone.WallClock like a named zone, on the picker's own
local clock, a seam that lets a test stand the machine anywhere without
moving time.Local.
The picker now says whether the reader has answered it: typed into the date
or the time, stepped the date, or chosen a zone, even the one already on it.
The event form needs that to tell its own defaults from the reader's choices.
A zone name can come from HEY, as an event's zone or the account's, and was
drawn as it came, on the form and in the open list. Both now go through
terminal.SanitizeLine, and the sink manifest lists the picker's zone fields
so a raw write of them fails TestSinksAreSanitized.
…edits
The form sent the clock it showed and checked the moments before they were
written, so a save could move an event without a word. A title-only edit of
an event ending at the second 01:30 of the night New York falls back sent
01:30 back, which HEY places at the first, an hour earlier; a Local end
written on a zoned start's clock could land the same way. Every end is now
checked as HEY will read what it is sent (timezone.Placed): an end the reader
has not touched must come back as the instant the event already has, and
one that would not is refused with how far it would move, as hey event edit
refuses it. Retyping the time is choosing HEY's reading of it. The order
check is on the placed instants too.
A late identity read now gives an all-day event being edited the account's
zone for the times it would get by being made timed, as the read would have
had it landed first. Whether the reader has answered is read from the
pickers themselves, so choosing Local, which leaves the form looking as it
did, is no longer taken back when the read lands.
A new event's default end is the first at least an hour after its start
that HEY places where it is shown, so Lord Howe's half-hour fall-back gives
01:00 to 02:00 rather than a half-hour event.
The tracked-time form read both ends off their pickers, and a picker shows a
clock time: a track ending at the second 01:30 of the night New York falls
back reads back as the first, now that the picker reads a Local time as HEY
does. A save that changed only the category then sent the end an hour
earlier. An end the reader has not touched is now the instant it arrived
with, for the payload, the order check and the length shown.
The default end stepped a quarter of an hour of elapsed time at a time from
an hour after the start and took the first instant a clock time could name.
A start in a zone whose offset kept seconds is between whole minutes itself:
Monrovia's 23:00 on 6 January 1972 was 23:44:30 UTC, every step kept the half
minute, none could be sent, and the fallback offered 00:44, which HEY moves
an hour on because the clocks skipped it, making a two-hour event.
timezone.FirstClockFrom searches clock times a minute at a time instead, for
the first that HEY places no earlier than an hour after the start and places
where it reads. Monrovia's is 00:45; New York's and Lord Howe's fall-backs
still give 01:00 to 02:00. It replaces timezone.Representable, which nothing
else used.
Whether an end kept the instant it arrived with was asked of the reader's
touch rather than of the value: choosing the zone already chosen, typing a
digit and taking it back, or stepping the date there and back sent the end
as HEY reads its clock. An end at the second 01:30 of the night New York
falls back then went back at the first, 06:30 UTC saved as 05:30 with no
word, and a time with seconds lost them. The tracked-time form did the same
on a category-only save. Both now keep an end's instant while its date, time
and zone show what they opened with, however the reader got back there; only
a real change is read as HEY reads it.
A kept end HEY cannot be sent as it is is still refused, and since typing the
same clock back no longer counts as retyping it, the refusal says the way
out: choose another time, or press ctrl+s again, with nothing pressed in
between, to save it where HEY reads it.
A late identity read no longer changes the days of a new form whose reader
switched All day. On a UTC machine at 23:30 on the 14th the form offers the
15th; taking Los Angeles' zone after the switch moved it to the 14th. The
switch now counts as an answer, as a chosen zone does.
…'s clock shows
TestEditingAnEventWithSecondsIsRefused typed a 0 back over the last digit of a start shown on the machine's own clock, which under Chatham's +12:45 ends in 5, so the test changed the time instead of undoing a keystroke. It now types back the digit it took.
At 01:00:00 on the second pass of the night New York falls back, 06:00 UTC,
the next whole hour on the clock is the hour it is already at, and HEY reads
2026-11-01 01:00 America/New_York as the first, 05:00 UTC: the form offered a
start an hour in the past. On the clock's own day the default start is now
the first whole hour HEY places no earlier than now, 02:00 there. A day in
view the clock has left or not reached keeps that day's hour, as before.
…one lands
A new event opened while the identity read was still out, then saved, took
the account's zone the moment the read landed: the form rewrote its times
and zone while the write it had already sent carried the Local ones, so the
screen and the saved event disagreed, and a failed save handed the reader
back values they never saw. Pressing ctrl+s now counts as accepting the form
as it stands, whether the save goes out, fails, or is refused, and a form
that is saving is never touched.
A new event was offered the next whole hour after the moment the view was
moved to its day, which the view keeps as its anchor. Press n at 09:15 to
look at tomorrow, leave the TUI open until 15:10 the next day, and the form
opened on that now-current day offered 10:00, hours gone. When the day in
view is today, the form now starts from the view's clock read now; any other
day keeps the hour it carries.
The failed-save case set the form's saving flag off by hand. It now saves
against a server that refuses the create, feeds the answer through the
calendar view's calendarMutationMsg handling, and only then lands the
identity read, so a change to how the view hands a failed form back is
caught too.
docs/tui.md now says that any ctrl+s, refused or failed included, stops a late account zone from changing the form, and that Local is saved without a zone only when both ends are on it: a Local end beside a zoned one is written in that zone, since HEY keeps a zone for both ends or neither.
The refusal named the first end HEY would place away from the moment meant,
and a second ctrl+s accepted it as a whole. With both ends imported with
seconds, only the start was named, and the second press moved the end too
without a word. The refusal now names each end that would move, and says
how far, before the second press saves them where HEY reads them.
The default start took its hour from the account's clock and its date from
the machine's. When the two clocks were on different dates it offered a time
on neither day: at 17:30 on 14 October in Los Angeles, with a Madrid account,
it offered 03:00 on the 14th in Madrid, 18:00 on the 13th in Los Angeles,
23 and a half hours before the form was opened, and the save checks passed
it because its ends agreed with what was sent.
The start is now the first whole hour on the account's clock that HEY places
at or after the moment the form is opened from, date and hour together:
03:00 on the 15th in Madrid there, 18:00 on the 14th in Los Angeles, on the
day the reader is looking at. Being the first at or after that moment, it
lands on the day in view whenever the day has an hour left, and only past
midnight in its last hour. Skipped and repeated hours are passed over as
before. The cross-date test asserted the old start and now checks the
instant, with the reverse case, another day in view and the night Madrid
falls back beside it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
hey event addreads clock times in the HEY account's time zone since #498, and so does HEY's web app. The TUI's event form did not: it opened on Local, read the typed time on the machine's clock, and sent it converted to UTC with no zone. The same 10:00 typed in the TUI, the CLI and the web could land in three different places, and a TUI event was always saved zoneless.What changes
A new event opens on the account's zone. The Starts and Ends rows name it (e.g.
America/Indiana/Indianapolis), and the write sends it as a named zone, so HEY places the wall clock itself — no local conversion. The default start is the first whole hour on the account's clock (date and hour both taken from that clock) that HEY places at or after the moment the form opens. That keeps it on the day in view as the grid draws it, even when the account's clock is already on another date: at 17:30 in Los Angeles with a Madrid account it offers 03:00 on the next day in Madrid, which is 18:00 the same day in Los Angeles. On the clock's own day that is the next hour HEY places at or after now: at the second 01:00 of a fall-back night it is 02:00, not the 01:00 HEY would place an hour in the past. A view pinned to a day that has since become today reads the clock again, so it doesn't offer an hour that has already passed. The default end is the first time at least an hour later that HEY places where it is shown, found by searching clock times minute by minute. A fall-back night gives 01:00–02:00 in New York and at Lord Howe, and Monrovia's 1972 half-minute offset gives 23:00–00:45.Where the zone comes from. The Calendar already reads the identity every time it is entered (
fetchIdentity, for the week start and clock format). That read now carriestime_zonetoo, and the form is handed it. No new request and no process-wide cache. The zone is cleared when the read starts, so an earlier visit's answer never stands in for it. A new-event form opened before the answer lands opens on Local and switches to the account's zone when the answer arrives, unless the reader has already changed the day, a time or a zone.Fallback is Local, never a refusal. An account with no zone, a failed identity read, or a zone HEY would not look up opens the form on Local, as before. Unlike the CLI, which refuses, the form shows which zone it is using, so the reader can see it and change it.
Local and the zone list stay. Choosing Local still sends the time as UTC with no zone.
Edits keep the event's own terms, as
hey event editdoes: a zoned event keeps its zone, and a timed event saved without one stays on Local and goes back zoneless. The CLI reads a typed time on a zoneless event in the account's zone; the form shows those times on the clock the calendar grid draws them on, so the reader types on the clock they are reading. An all-day event given a time takes the account's zone, as the CLI gives it.Shared with the CLI. A new
internal/timezonepackage holds what the CLI already did and the form now needs.timezone.Load(formerlyloadEventZone) validates zone names the way HEY looks them up, with the embedded zone database.timezone.WallClock(formerlywallClockOn) places a clock time the way HEY places one when the clocks skip or repeat it. The form usesLoadfor its zone check andWallClockfor its order check and the default start. So 02:30 to 03:00 on the morning New York springs forward is refused, since HEY moves 02:30 on to 03:30, and a form opened at 01:41 that morning offers 03:00.A save never moves an event silently. Every end is checked against where HEY will place what it is sent (
timezone.Placed). An end whose date, time and zone still show what they opened with keeps the instant the event already has. That holds however the reader got back to those values: reselecting the same zone, typing and undoing, or stepping the date and back. Only a real change is read the way HEY reads a clock time. A kept end HEY can't represent is refused, with how far it would move: one at the second 01:30 of a fall-back night (HEY takes the first), or one with seconds. The refusal names every end that would move, then says to choose other times, or press ctrl+s again with nothing pressed in between to save them where HEY reads them. The same check catches a Local end written on a zoned start's clock that would land on the other moment. The tracked-time form keeps unchanged ends the same way.Local is read the way HEY reads it. A time typed on Local goes through
WallClockas well, so 02:30 on New York's spring-forward morning is 03:30, as in the CLI.The reader's choices outrank a late identity read. Editing a date or time, choosing a zone (even the one already shown, such as Local), or switching All day all count as answers, so a late identity read never overrides them. A new form's days stay as the reader saw them. Pressing ctrl+s also counts, whether the save goes out, fails or is refused, so a form whose write is in flight always shows what it is writing. The same read gives an untouched all-day edit the account's zone for when it is made timed.
Zone names are sanitized on screen, both on the form and in the open list. The sink manifest now lists the picker's zone fields.
Two small fixes this exposed
TestNewEventFormOpensOnTheDayInViewandTestCreateEventPostsTheFormnow pass underTZ=Asia/KolkataandPacific/Chatham.Not changed
The time tracking form uses the same picker and still opens on Local.