From cefb9939c3e16486a5bd3572aba3304a424503df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 01:21:01 +0000 Subject: [PATCH] Align HEY client with hey-cli v1.0 and hey-sdk 0.21 Use the official calendar event form API (including set_time_zone), week-period reads for expanded recurrences, date-only todo writes, canonical email box paths, and time-track start/stop payloads. Timeboxes now PATCH an existing HEY event instead of delete-and-recreate. Co-authored-by: is2b007 --- app/controllers/calendar_events_controller.rb | 3 +- app/controllers/timer_sessions_controller.rb | 7 +- app/jobs/sync_calendar_events_job.rb | 13 +- app/jobs/sync_sometime_todo_to_hey_job.rb | 9 +- app/jobs/sync_timebox_to_hey_job.rb | 34 +- app/services/hey_client.rb | 405 ++++++++++++------ app/views/hey_connections/new.html.erb | 65 +-- test/jobs/sync_calendar_events_job_test.rb | 33 ++ .../sync_sometime_todo_to_hey_job_test.rb | 17 +- test/jobs/sync_timebox_to_hey_job_test.rb | 39 +- test/services/hey_client_test.rb | 262 ++++++++++- 11 files changed, 688 insertions(+), 199 deletions(-) diff --git a/app/controllers/calendar_events_controller.rb b/app/controllers/calendar_events_controller.rb index ba0e55f..be5c03b 100644 --- a/app/controllers/calendar_events_controller.rb +++ b/app/controllers/calendar_events_controller.rb @@ -142,7 +142,8 @@ def apply_calendar_update!(starts:, ends:, title:) title: title, starts_at: starts, ends_at: ends, - all_day: @event.all_day + all_day: @event.all_day, + time_zone: current_user.timezone ) if result.nil? diff --git a/app/controllers/timer_sessions_controller.rb b/app/controllers/timer_sessions_controller.rb index 3b6069b..2e001c4 100644 --- a/app/controllers/timer_sessions_controller.rb +++ b/app/controllers/timer_sessions_controller.rb @@ -19,7 +19,7 @@ def create if current_user.hey_connected? && task begin client = HeyClient.new(current_user) - result = client.start_time_track(title: task.title) + result = client.start_time_track if result.is_a?(Hash) && result["id"].present? @timer.update_column(:hey_time_track_id, result["id"].to_s) end @@ -47,7 +47,10 @@ def stop_hey_time_track(timer) return if timer.hey_time_track_id.blank? return unless current_user.hey_connected? - HeyClient.new(current_user).stop_time_track(timer.hey_time_track_id) + HeyClient.new(current_user).stop_time_track( + timer.hey_time_track_id, + category_title: timer.task_assignment&.title + ) rescue StandardError => e Rails.logger.warn("HEY time track stop failed: #{e.message}") end diff --git a/app/jobs/sync_calendar_events_job.rb b/app/jobs/sync_calendar_events_job.rb index 3b01871..829f61d 100644 --- a/app/jobs/sync_calendar_events_job.rb +++ b/app/jobs/sync_calendar_events_job.rb @@ -70,10 +70,17 @@ def upsert_basecamp(user, entry, week_start, week_end) def sync_hey(user, week_start, week_end) client = HeyClient.new(user) - events = client.calendar_events(starts_on: week_start.iso8601, ends_on: week_end.iso8601) - if events.is_a?(Array) - dedupe_hey_recordings(events).each { |evt| upsert_hey(user, evt) } + events = [] + + if client.respond_to?(:calendar_week_events) + week_rows = client.calendar_week_events(week_start.iso8601) + events.concat(week_rows) if week_rows.is_a?(Array) end + + recordings = client.calendar_events(starts_on: week_start.iso8601, ends_on: week_end.iso8601) + events.concat(recordings) if recordings.is_a?(Array) + + dedupe_hey_recordings(events).each { |evt| upsert_hey(user, evt) } reconcile_duplicate_hey_calendar_rows!(user) true rescue HeyClient::AuthError => e diff --git a/app/jobs/sync_sometime_todo_to_hey_job.rb b/app/jobs/sync_sometime_todo_to_hey_job.rb index 4881247..1cb80d7 100644 --- a/app/jobs/sync_sometime_todo_to_hey_job.rb +++ b/app/jobs/sync_sometime_todo_to_hey_job.rb @@ -8,14 +8,19 @@ def perform(task_assignment_id) user = task.user return unless user.hey_connected? return unless task.week_bucket == "sometime" - return if task.hey_mirrored_todo_id.present? week_start = task.week_start_date || user.current_week_start tz = ActiveSupport::TimeZone[user.timezone] || Time.zone anchor = tz.local(week_start.year, week_start.month, week_start.day, 12, 0) + 6.days + starts_on = anchor.to_date.iso8601 client = HeyClient.new(user) - result = client.create_todo(title: task.title, starts_at: anchor.to_date.iso8601) + if task.hey_mirrored_todo_id.present? + client.update_todo(task.hey_mirrored_todo_id, title: task.title, starts_at: starts_on) + return + end + + result = client.create_todo(title: task.title, starts_at: starts_on) new_id = extract_todo_id(result) return if new_id.blank? diff --git a/app/jobs/sync_timebox_to_hey_job.rb b/app/jobs/sync_timebox_to_hey_job.rb index 0f8c990..06d2dea 100644 --- a/app/jobs/sync_timebox_to_hey_job.rb +++ b/app/jobs/sync_timebox_to_hey_job.rb @@ -14,8 +14,6 @@ def perform(task_assignment_id) CalendarEvent.destroy_daybreak_timebox_mirror!(user, task.id) prev_id = task.hey_calendar_event_id - client.delete_timebox_mirror_remote_id(prev_id) if prev_id.present? - cal_id = client.calendar_id_for_timed_writes tz_name = user.timezone.presence || "UTC" zone = ActiveSupport::TimeZone[tz_name] || Time.zone @@ -26,13 +24,31 @@ def perform(task_assignment_id) new_id = nil if cal_id.present? - new_id = client.create_timed_calendar_event_form( - calendar_id: cal_id, - title: task.title, - local_start: local_start, - local_end: local_end, - time_zone: tz_name - ) + if prev_id.present? + patched = client.update_calendar_event( + calendar_id: cal_id, + event_id: prev_id, + title: task.title, + starts_at: local_start, + ends_at: local_end, + all_day: false, + time_zone: tz_name + ) + new_id = patched.presence + end + + if new_id.blank? + new_id = client.create_timed_calendar_event_form( + calendar_id: cal_id, + title: task.title, + local_start: local_start, + local_end: local_end, + time_zone: tz_name + ) + if prev_id.present? && new_id.present? && new_id.to_s != prev_id.to_s + client.delete_timebox_mirror_remote_id(prev_id) + end + end end d = task.planned_start_at.in_time_zone(user.timezone).to_date diff --git a/app/services/hey_client.rb b/app/services/hey_client.rb index cd22178..a37871a 100644 --- a/app/services/hey_client.rb +++ b/app/services/hey_client.rb @@ -157,19 +157,38 @@ def todos recordings_calendar_todos(raw) end - # +starts_at+ may be Time or Date; serialized as ISO8601 (hey-sdk OpenAPI: date or time string). + # Week period: recurring events are expanded into the occurrences that fall + # inside the week. Recordings list a series once, on the day it was created. + def calendar_week(date) + get("/calendar/weeks/#{date}.json") + end + + def calendar_week_events(date) + flatten_calendar_period(calendar_week(date)) + end + + # +starts_at+ is a bare YYYY-MM-DD. An RFC 3339 midnight can land on the + # previous day once HEY casts it in the user's zone (hey-sdk CalendarTodos). def create_todo(title:, starts_at: nil, ends_at: nil) inner = { title: title.to_s, - starts_at: starts_at.respond_to?(:iso8601) ? starts_at.iso8601 : starts_at.to_s - }.compact - # Non-standard field; HEY may ignore. Omitted if not set. - inner[:ends_at] = ends_at.iso8601 if ends_at.respond_to?(:iso8601) + starts_at: coerce_todo_date(starts_at) || Date.current.iso8601 + } post("/calendar/todos.json", { "calendar_todo" => inner }) end + def update_todo(todo_id, title: nil, starts_at: nil, focused: nil) + changes = {} + changes[:title] = title.to_s if title.present? + changes[:starts_at] = coerce_todo_date(starts_at) if starts_at.present? + changes[:focused] = focused unless focused.nil? + return nil if changes.empty? + + patch("/calendar/todos/#{todo_id}.json", { "calendar_todo" => changes }) + end + def delete_todo(todo_id) - delete("/calendar/todos/#{todo_id}") + delete("/calendar/todos/#{todo_id}.json") end def complete_todo(todo_id) @@ -198,11 +217,17 @@ def flatten_calendar_recordings(raw, calendar_id:, color: nil) rid = rec["id"] next if rid.blank? + cid = calendar_id.presence + if cid.blank? && rec["calendar"].is_a?(Hash) + cid = rec.dig("calendar", "id").to_s.presence + end + merged = rec.merge( "id" => rid.to_s, - "hey_calendar_id" => calendar_id + "hey_calendar_id" => cid ) merged["calendar_color"] = color if color.present? + stamp_hey_event_identity!(merged) rows << merged end end @@ -217,76 +242,113 @@ def flatten_calendar_recordings(raw, calendar_id:, color: nil) rows.uniq { |r| [ r["hey_calendar_id"], r["id"] ] } end - # Synced HEY calendar rows (drag/resize/delete in Daybreak): JSON under /calendars/:id/events… - # hey-sdk OpenAPI documents Bearer calendar *writes* for todos (`POST /calendar/todos.json`); - # session-only form routes like `POST /calendar/events` return 404 for OAuth clients (see debug H6). - def update_calendar_event(calendar_id:, event_id:, title: nil, starts_at: nil, ends_at: nil, all_day: nil) - attrs = {}.tap do |h| - h[:title] = title if title.present? - h[:starts_at] = starts_at.iso8601 if starts_at.respond_to?(:iso8601) - h[:ends_at] = ends_at.iso8601 if ends_at.respond_to?(:iso8601) - h[:all_day] = all_day unless all_day.nil? + # Week/day period payload: { starts_at, ends_at, kind, recordings: { "Calendar::Event" => [...] } } + def flatten_calendar_period(raw) + return [] if raw.blank? + return [] unless raw.is_a?(Hash) + + data = raw.stringify_keys + recordings = data["recordings"] || data + return [] unless recordings.is_a?(Hash) || recordings.is_a?(Array) + + if recordings.is_a?(Hash) + event_keys = recordings.keys.select { |k| k.to_s.match?(/event/i) && !k.to_s.match?(/todo/i) } + subset = event_keys.any? ? recordings.slice(*event_keys) : recordings + rows = flatten_calendar_recordings(subset, calendar_id: nil) + rows.reject! { |r| r["type"].to_s.match?(/todo/i) } if event_keys.empty? + rows + else + flatten_calendar_recordings(recordings, calendar_id: nil) end - return nil if attrs.empty? + end - patch("/calendars/#{calendar_id}/events/#{event_id}.json", { "calendar_event" => attrs }) + # Official writes (hey-sdk CalendarEventsService): form to /calendar/events.json + # with calendar_event[set_time_zone]=1 so zone names are not dropped. + def update_calendar_event(calendar_id:, event_id:, title: nil, starts_at: nil, ends_at: nil, all_day: nil, time_zone: nil) + return nil if starts_at.blank? || ends_at.blank? + + ref = parse_event_ref(event_id) + tz = time_zone.to_s.presence || @user.timezone.presence || "UTC" + pairs = calendar_event_form_pairs( + calendar_id: calendar_id, + title: title, + starts_at: starts_at, + ends_at: ends_at, + all_day: all_day, + time_zone: tz + ) + path = if ref[:occurrence] + "/calendar/events/#{ref[:series_id]}/occurrences/#{ref[:date]}.json" + else + "/calendar/events/#{ref[:series_id]}.json" + end + meta = form_request(:patch, path, pairs) + return nil unless form_write_ok?(meta) + + extract_event_id_from_form_meta(meta).presence || event_id.to_s end - def delete_calendar_event(calendar_id:, event_id:) - delete("/calendars/#{calendar_id}/events/#{event_id}.json") + def delete_calendar_event(calendar_id: nil, event_id:) + ref = parse_event_ref(event_id) + path = if ref[:occurrence] + "/calendar/events/#{ref[:series_id]}/occurrences/#{ref[:date]}.json" + else + "/calendar/events/#{ref[:series_id]}" + end + meta = form_request(:delete, path, nil) + return true if form_write_ok?(meta) + + # Last-resort JSON delete for leftover nested-path ids. + return false if calendar_id.blank? + + !delete("/calendars/#{calendar_id}/events/#{ref[:series_id]}.json").nil? end - def create_calendar_event(calendar_id:, title:, starts_at:, ends_at:, all_day: false) - attrs = { - title: title.to_s, - starts_at: starts_at.respond_to?(:iso8601) ? starts_at.iso8601 : starts_at.to_s, - ends_at: ends_at.respond_to?(:iso8601) ? ends_at.iso8601 : ends_at.to_s, + def create_calendar_event(calendar_id:, title:, starts_at:, ends_at:, all_day: false, time_zone: nil) + create_timed_calendar_event_form( + calendar_id: calendar_id, + title: title, + local_start: starts_at, + local_end: ends_at, + time_zone: time_zone || @user.timezone.presence || "UTC", all_day: all_day - } - post("/calendars/#{calendar_id}/events.json", { "calendar_event" => attrs }) + ) end - # Timed HEY calendar mirror: try hey-sdk browser form first, then JSON create (runtime: OAuth gets 404 on form — debug H7). - # Form matches go/pkg/hey/calendar_events.go; JSON matches #create_calendar_event for Bearer. - def create_timed_calendar_event_form(calendar_id:, title:, local_start:, local_end:, time_zone:) - tz = time_zone.to_s.presence || "UTC" - ls = local_start - le = local_end - starts_date = ls.to_date.iso8601 - ends_date = le.to_date.iso8601 - form_pairs = [ - [ "calendar_event[calendar_id]", calendar_id.to_s ], - [ "calendar_event[summary]", title.to_s ], - [ "calendar_event[starts_at]", starts_date ], - [ "calendar_event[ends_at]", ends_date ], - [ "calendar_event[all_day]", "0" ], - [ "calendar_event[starts_at_time]", "#{ls.strftime("%H:%M")}:00" ], - [ "calendar_event[ends_at_time]", "#{le.strftime("%H:%M")}:00" ], - [ "calendar_event[starts_at_time_zone_name]", tz ], - [ "calendar_event[ends_at_time_zone_name]", tz ] - ] - meta = form_request(:post, "/calendar/events", form_pairs) - id = extract_form_redirect_event_id(meta) - return id if id.present? - - json_body = { - "calendar_event" => { - "title" => title.to_s, - "starts_at" => ls.iso8601, - "ends_at" => le.iso8601, - "all_day" => false - } - } - jmeta = json_post_with_meta("/calendars/#{calendar_id}/events.json", json_body) - return nil unless jmeta[:success] - - extract_json_calendar_event_id(jmeta[:json]) + def create_timed_calendar_event_form(calendar_id:, title:, local_start:, local_end:, time_zone:, all_day: false) + pairs = calendar_event_form_pairs( + calendar_id: calendar_id, + title: title, + starts_at: local_start, + ends_at: local_end, + all_day: all_day, + time_zone: time_zone + ) + meta = form_request(:post, "/calendar/events.json", pairs) + extract_event_id_from_form_meta(meta) end def delete_calendar_event_form(event_id) - meta = form_request(:delete, "/calendar/events/#{event_id}", nil) - code = meta[:code].to_i - code == 302 || code == 303 || (code >= 200 && code < 300) + delete_calendar_event(event_id: event_id) + end + + # Official calendar delete first; leftover sometime-todo mirrors fall back to todo delete. + def delete_timebox_mirror_remote_id(remote_id) + return if remote_id.blank? + + cal_ok = false + begin + cal_ok = delete_calendar_event(event_id: remote_id) + rescue StandardError + cal_ok = false + end + return if cal_ok + + begin + delete_todo(remote_id) + rescue StandardError + nil + end end # Habits @@ -298,15 +360,23 @@ def complete_habit(day, habit_id) # Time tracking def current_time_track - get("/calendar/ongoing_time_track.json") + get("/calendar/ongoing_time_track.json", allow: [ 404 ]) end + # HEY ignores the start body and starts a track with defaults. 409 means a + # track is already running — adopt GET /calendar/ongoing_time_track.json. def start_time_track(title: nil) - post("/calendar/ongoing_time_track.json", { title: title }.compact) + data = post("/calendar/ongoing_time_track.json", allow: [ 409 ]) + return current_time_track if @last_status_code == 409 + return data if data.is_a?(Hash) && data["id"].present? + + current_time_track || data end - def stop_time_track(time_track_id) - put("/calendar/time_tracks/#{time_track_id}.json", { ends_at: Time.current.iso8601 }) + def stop_time_track(time_track_id, category_title: nil) + inner = { ends_at: Time.current.iso8601 } + inner[:category_title] = category_title if category_title.present? + put("/calendar/time_tracks/#{time_track_id}.json", { "calendar_time_track" => inner }) end # Journal @@ -339,11 +409,11 @@ def imbox end def reply_later - fetch_box("/reply_later.json") + fetch_box("/laterbox.json") end def set_aside - fetch_box("/set_aside.json") + fetch_box("/asidebox.json") end def feed @@ -351,13 +421,14 @@ def feed end def paper_trail - fetch_box("/paper_trail.json") + fetch_box("/trailbox.json") end private - # Returns postings from a BoxShowResponse, following next_history_url until +max_postings+. - # Canonical paths per hey-sdk openapi (not /laterbox.json etc.). + # Returns postings from a BoxShowResponse, following next_history_url or a + # same-origin Link: rel=next header (geared_pagination) until +max_postings+. + # Canonical paths per hey-sdk: /laterbox.json, /asidebox.json, /trailbox.json. # nil on first-request failure; [] if the box is empty. def fetch_box(initial_path, max_postings: 200) all = [] @@ -378,9 +449,11 @@ def fetch_box(initial_path, max_postings: 200) break if all.size >= max_postings nxt = data["next_history_url"].presence + nxt ||= next_path_from_link_header(@last_link_header) break if nxt.blank? - next_path = path_from_hey_url(nxt) || break + next_path = path_from_hey_url(nxt) || nxt + break if next_path.blank? end all.first(max_postings) end @@ -449,57 +522,27 @@ def recordings_calendar_todos(raw) end end - def get(path) - request(:get, path) - end - - def post(path, body = nil) - request(:post, path, body) + def get(path, allow: []) + request(:get, path, allow: allow) end - def put(path, body) - request(:put, path, body) + def post(path, body = nil, allow: []) + request(:post, path, body, allow: allow) end - def patch(path, body) - request(:patch, path, body) + def put(path, body, allow: []) + request(:put, path, body, allow: allow) end - def delete(path) - request(:delete, path) + def patch(path, body, allow: []) + request(:patch, path, body, allow: allow) end - # Removes a timebox mirror id: form calendar delete, JSON calendar delete, then legacy todo mirrors. - def delete_timebox_mirror_remote_id(remote_id) - return if remote_id.blank? - - cal_ok = false - begin - cal_ok = delete_calendar_event_form(remote_id) - rescue StandardError - cal_ok = false - end - return if cal_ok - - cid = calendar_id_for_timed_writes - if cid.present? - json_del = nil - begin - json_del = delete_calendar_event(calendar_id: cid, event_id: remote_id) - rescue StandardError - json_del = nil - end - return unless json_del.nil? - end - - begin - delete_todo(remote_id) - rescue StandardError - nil - end + def delete(path, allow: []) + request(:delete, path, allow: allow) end - def request(method, path, body = nil) + def request(method, path, body = nil, allow: []) ensure_fresh_token! uri = URI("#{BASE_API_URL}#{path}") @@ -520,6 +563,8 @@ def request(method, path, body = nil) req.body = body.to_json if body response = http_start(uri) { |http| http.request(req) } + @last_status_code = response.code.to_i + @last_link_header = response["Link"] case response when Net::HTTPSuccess @@ -533,8 +578,10 @@ def request(method, path, body = nil) JSON.parse(body_str) when Net::HTTPUnauthorized - refresh_and_retry!(method, path, body) + refresh_and_retry!(method, path, body, allow: allow) else + return nil if allow.include?(@last_status_code) + Rails.logger.error("HEY API error: #{response.code} #{response.body}") nil end @@ -562,9 +609,9 @@ def perform_token_refresh! end end - def refresh_and_retry!(method, path, body) + def refresh_and_retry!(method, path, body, allow: []) perform_token_refresh! - request(method, path, body) + request(method, path, body, allow: allow) rescue AuthError raise AuthError, "HEY session expired. Reconnect from Settings." rescue StandardError => e @@ -587,7 +634,7 @@ def form_request(method, path, form_pairs) raise rescue StandardError => e Rails.logger.error("HEY form request error: #{e.class} #{e.message}") - { code: 0, location: nil, unauthorized: false } + { code: 0, location: nil, body: nil, unauthorized: false, success: false } end def perform_form_http(method, path, form_pairs) @@ -609,10 +656,13 @@ def perform_form_http(method, path, form_pairs) end res = http_start(uri) { |http| http.request(req) } + code = res.code.to_i { - code: res.code.to_i, + code: code, location: res["Location"], - unauthorized: res.is_a?(Net::HTTPUnauthorized) + body: res.body.to_s, + unauthorized: res.is_a?(Net::HTTPUnauthorized), + success: res.is_a?(Net::HTTPSuccess) || [ 302, 303 ].include?(code) } end @@ -667,6 +717,115 @@ def single_json_post(path, body) def extract_json_calendar_event_id(data) return nil unless data.is_a?(Hash) - data["id"]&.to_s || data.dig("calendar_event", "id")&.to_s + data["id"]&.to_s || data.dig("calendar_event", "id")&.to_s || data.dig("recording", "id")&.to_s + end + + def extract_event_id_from_form_meta(meta) + return nil unless form_write_ok?(meta) + + body = meta[:body].to_s.strip + if body.present? + parsed = JSON.parse(body) rescue nil + id = extract_json_calendar_event_id(parsed) + return id if id.present? + end + extract_form_redirect_event_id(meta) + end + + def form_write_ok?(meta) + return false if meta.nil? + + code = meta[:code].to_i + meta[:success] == true || (code >= 200 && code < 300) || [ 302, 303 ].include?(code) + end + + def calendar_event_form_pairs(calendar_id:, title:, starts_at:, ends_at:, all_day:, time_zone:) + tz = time_zone.to_s.presence || "UTC" + ls = starts_at + le = ends_at || starts_at + pairs = [ + [ "calendar_event[calendar_id]", calendar_id.to_s ], + [ "calendar_event[summary]", title.to_s ], + [ "calendar_event[starts_at]", date_only(ls) ], + [ "calendar_event[ends_at]", date_only(le) ] + ] + if all_day + pairs << [ "calendar_event[all_day]", "1" ] + else + pairs << [ "calendar_event[all_day]", "0" ] + pairs << [ "calendar_event[starts_at_time]", clock_time(ls) ] + pairs << [ "calendar_event[ends_at_time]", clock_time(le) ] + pairs << [ "calendar_event[set_time_zone]", "1" ] + pairs << [ "calendar_event[starts_at_time_zone_name]", tz ] + pairs << [ "calendar_event[ends_at_time_zone_name]", tz ] + end + pairs + end + + def date_only(value) + return value.to_date.iso8601 if value.respond_to?(:to_date) + + value.to_s[0, 10] + end + + def clock_time(value) + return "#{value.strftime("%H:%M")}:00" if value.respond_to?(:strftime) + + str = value.to_s + if (m = str.match(/T(\d{2}:\d{2})/)) + return "#{m[1]}:00" + end + + "00:00:00" + end + + def coerce_todo_date(value) + return nil if value.blank? + return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/) + return value.to_date.iso8601 if value.respond_to?(:to_date) + + Date.iso8601(value.to_s).iso8601 + rescue ArgumentError + value.to_s[0, 10] + end + + EVENT_OCCURRENCE_REF = /\A(\d+):(\d{4}-\d{2}-\d{2})\z/ + + def parse_event_ref(event_id) + if event_id.to_s =~ EVENT_OCCURRENCE_REF + { series_id: ::Regexp.last_match(1), date: ::Regexp.last_match(2), occurrence: true } + else + { series_id: event_id.to_s, date: nil, occurrence: false } + end + end + + def stamp_hey_event_identity!(rec) + occ = rec["occurrence_id"] || rec["occurrenceId"] + parent = rec["parent_id"] || rec["parentId"] + parent ||= rec.dig("parent", "id") if rec["parent"].is_a?(Hash) + series_id = (parent.presence || rec["id"]).to_s + starts = rec["starts_at"] || rec["startsAt"] + + if occ.present? && series_id.present? && starts.present? + date = begin + Time.zone.parse(starts.to_s).to_date.iso8601 + rescue ArgumentError, TypeError + nil + end + rec["id"] = "#{series_id}:#{date}" if date + end + rec + end + + def next_path_from_link_header(link) + return nil if link.blank? + + part = link.to_s.split(",").map(&:strip).find do |p| + p.include?('rel="next"') || p.include?("rel=next") || p.include?("rel='next'") + end + return nil unless part + + url = part[/<([^>]+)>/, 1] + path_from_hey_url(url) || url end end diff --git a/app/views/hey_connections/new.html.erb b/app/views/hey_connections/new.html.erb index 2b67393..4acf2b6 100644 --- a/app/views/hey_connections/new.html.erb +++ b/app/views/hey_connections/new.html.erb @@ -12,8 +12,7 @@

Connect HEY

- Install the official hey CLI once, grab a token, paste it here. - You need Go 1.26 or newer on your machine for every install path below. + Install the official hey CLI, grab a token, paste it here.

@@ -41,65 +40,39 @@
Install the HEY CLI (skip if hey already works)

- The old Homebrew formula (brew install basecamp/hey/hey) is dead — the tap repo was removed. HEY’s CLI is Go-based; you need Go 1.26+. Pick one path: + Official installer from HEY — downloads the signed release for your platform:

-
Recommended — macOS + Homebrew
-
- brew install go -go version # must be 1.26+ -go install github.com/basecamp/hey-cli/cmd/hey@latest -export PATH="$(go env GOPATH)/bin:$PATH" -hey version -
-

Add the export PATH=… line to ~/.zshrc so new terminals find hey.

-
Already have Go 1.26+?
+
Homebrew
- go install github.com/basecamp/hey-cli/cmd/hey@latest - -
-

Then export PATH="$(go env GOPATH)/bin:$PATH". No Go? go.dev/dl.

- -
No mise, no sudo — build in the repo folder
-
- git clone https://github.com/basecamp/hey-cli.git -cd hey-cli -go build -o ./hey ./cmd/hey -./hey version -
-

Use ./hey auth login and ./hey auth token from that directory, or move ./hey onto your PATH.

- Stuck? Common errors -
    -
  • command not found: go — Install Go from go.dev/dl or run brew install go.
  • -
  • go.mod requires Go ≥1.26 — Upgrade Go (brew upgrade go or a newer installer).
  • -
  • hey: command not found after go install — Run export PATH="$(go env GOPATH)/bin:$PATH" or use the ./hey build path above.
  • -
  • command not found: mise — Skip mise; use the blocks above instead.
  • -
+ Other install paths +

Windows (PowerShell): irm https://hey.com/install-cli.ps1 | iex

+

From source (Go 1.26+): go install github.com/basecamp/hey-cli/cmd/hey@latest

+

+ hey-cli README +

- -

- hey-cli README (upstream) -

@@ -116,7 +89,7 @@ go build -o ./hey ./cmd/hey Copy -

Opens your browser — sign in with HEY. If you only built ./hey in step 1, run ./hey auth login from that folder instead.

+

Opens your browser — sign in with HEY. hey login is an alias.

@@ -133,7 +106,7 @@ go build -o ./hey ./cmd/hey Copy -

On Linux use hey auth token | xclip -selection clipboard. With a local ./hey binary: ./hey auth token | pbcopy.

+

On Linux use hey auth token | xclip -selection clipboard.

diff --git a/test/jobs/sync_calendar_events_job_test.rb b/test/jobs/sync_calendar_events_job_test.rb index 97d28f8..3f3aea8 100644 --- a/test/jobs/sync_calendar_events_job_test.rb +++ b/test/jobs/sync_calendar_events_job_test.rb @@ -23,6 +23,7 @@ def with_hey_client(client) test "sync_hey uses provided week_start for calendar_events window" do seen = [] client = Object.new + client.define_singleton_method(:calendar_week_events) { |*| [] } client.define_singleton_method(:calendar_events) do |starts_on:, ends_on:| seen << [ starts_on, ends_on ] [] @@ -39,6 +40,7 @@ def with_hey_client(client) test "upserts hey event with camelCase keys" do client = Object.new + client.define_singleton_method(:calendar_week_events) { |*| [] } client.define_singleton_method(:calendar_events) do |starts_on:, ends_on:| [ { @@ -83,6 +85,7 @@ def with_hey_client(client) ) client = Object.new + client.define_singleton_method(:calendar_week_events) { |*| [] } client.define_singleton_method(:calendar_events) { |**_| [] } with_hey_client(client) do @@ -94,6 +97,7 @@ def with_hey_client(client) test "dedupes hey recordings that share title and time range but differ by calendar or id" do client = Object.new + client.define_singleton_method(:calendar_week_events) { |*| [] } client.define_singleton_method(:calendar_events) do |starts_on:, ends_on:| [ { @@ -127,6 +131,7 @@ def with_hey_client(client) test "upserts hey events with completed_at from flattened recordings" do client = Object.new + client.define_singleton_method(:calendar_week_events) { |*| [] } client.define_singleton_method(:calendar_events) do |starts_on:, ends_on:| [ { @@ -151,4 +156,32 @@ def with_hey_client(client) assert_equal "owner-99", ev.hey_calendar_id assert ev.completed_at.present? end + + test "upserts expanded recurring occurrence from calendar_week_events" do + week = @week + client = Object.new + client.define_singleton_method(:calendar_week_events) do |date| + raise "unexpected week #{date}" unless date == week.iso8601 + [ + { + "id" => "88:2026-04-15", + "hey_calendar_id" => "cal-1", + "title" => "Weekly standup", + "starts_at" => "2026-04-15T15:00:00Z", + "ends_at" => "2026-04-15T15:30:00Z", + "all_day" => false, + "occurrence_id" => "_" + } + ] + end + client.define_singleton_method(:calendar_events) { |**_| [] } + + with_hey_client(client) do + SyncCalendarEventsJob.perform_now(@user.id, week_start: @week.iso8601) + end + + ev = @user.calendar_events.find_by(external_id: "88:2026-04-15", source: :hey) + assert ev + assert_equal "Weekly standup", ev.title + end end diff --git a/test/jobs/sync_sometime_todo_to_hey_job_test.rb b/test/jobs/sync_sometime_todo_to_hey_job_test.rb index c0c5aa9..04f771d 100644 --- a/test/jobs/sync_sometime_todo_to_hey_job_test.rb +++ b/test/jobs/sync_sometime_todo_to_hey_job_test.rb @@ -43,18 +43,27 @@ def with_hey_client(client) assert_equal "todo-remote-1", @task.reload.hey_mirrored_todo_id end - test "skips when mirrored id already set" do + test "updates existing mirrored todo instead of creating another" do @task.update_column(:hey_mirrored_todo_id, "existing") - called = false + created = false + updated = nil client = Object.new - client.define_singleton_method(:create_todo) { |**_| called = true } + client.define_singleton_method(:create_todo) { |**_| created = true } + client.define_singleton_method(:update_todo) do |id, **kw| + updated = { id: id, **kw } + {} + end with_hey_client(client) do SyncSometimeTodoToHeyJob.perform_now(@task.id) end - assert_equal false, called + assert_equal false, created + assert_equal "existing", updated[:id] + assert_equal "Sometime task", updated[:title] + assert_equal "2026-04-19", updated[:starts_at] + assert_equal "existing", @task.reload.hey_mirrored_todo_id end test "creates todo without hey_app_url using week end anchor date" do diff --git a/test/jobs/sync_timebox_to_hey_job_test.rb b/test/jobs/sync_timebox_to_hey_job_test.rb index 3912a05..391ed9d 100644 --- a/test/jobs/sync_timebox_to_hey_job_test.rb +++ b/test/jobs/sync_timebox_to_hey_job_test.rb @@ -23,10 +23,19 @@ def create_timed_calendar_event_form(calendar_id:, title:, local_start:, local_e remote_id end + def update_calendar_event(**kwargs) + (@event_updates ||= []) << kwargs + kwargs[:event_id] + end + def event_creates @event_creates ||= [] end + def event_updates + @event_updates ||= [] + end + def mirror_deletes @mirror_deletes ||= [] end @@ -62,18 +71,32 @@ def mirror_deletes HeyClient.define_singleton_method(:new, @orig_hey_new) end - test "replaces existing HEY mirror by delete then create timed calendar event" do + test "patches existing HEY mirror instead of delete-and-recreate" do + SyncTimeboxToHeyJob.perform_now(@task.id) + + assert_empty @hey_fake.mirror_deletes + assert_empty @hey_fake.event_creates + assert_equal 1, @hey_fake.event_updates.size + u = @hey_fake.event_updates.last + assert_equal "cal-default", u[:calendar_id] + assert_equal "old-event-id", u[:event_id] + assert_equal "Boxed", u[:title] + assert_equal "America/Los_Angeles", u[:time_zone] + assert_equal "old-event-id", @task.reload.hey_calendar_event_id + assert_not @user.calendar_events.exists?(source: :daybreak, external_id: CalendarEvent.daybreak_timebox_external_id(@task.id)) + end + + test "creates a new event when existing mirror PATCH fails" do + @hey_fake.define_singleton_method(:update_calendar_event) do |**kwargs| + (@event_updates ||= []) << kwargs + nil + end + SyncTimeboxToHeyJob.perform_now(@task.id) - assert_equal %w[old-event-id], @hey_fake.mirror_deletes assert_equal 1, @hey_fake.event_creates.size - c = @hey_fake.event_creates.last - assert_equal "cal-default", c[:calendar_id] - assert_equal "Boxed", c[:title] - assert_equal "America/Los_Angeles", c[:time_zone] - assert_operator c[:local_end], :>, c[:local_start] + assert_equal %w[old-event-id], @hey_fake.mirror_deletes assert_equal "event-remote-new", @task.reload.hey_calendar_event_id - assert_not @user.calendar_events.exists?(source: :daybreak, external_id: CalendarEvent.daybreak_timebox_external_id(@task.id)) end test "creates calendar event when no prior mirror id" do diff --git a/test/services/hey_client_test.rb b/test/services/hey_client_test.rb index 810f8c3..2696be5 100644 --- a/test/services/hey_client_test.rb +++ b/test/services/hey_client_test.rb @@ -13,7 +13,7 @@ class HeyClientTest < ActiveSupport::TestCase test "write_journal sends calendar_journal_entry envelope per HEY API" do client = HeyClient.new(@user) captured = nil - client.define_singleton_method(:request) do |method, path, body| + client.define_singleton_method(:request) do |method, path, body = nil, allow: []| captured = { method: method, path: path, body: body } {} end @@ -181,4 +181,264 @@ class HeyClientTest < ActiveSupport::TestCase assert_equal 2, rows.size assert_equal %w[1 2], rows.map { |r| r["id"] }.sort end + + test "create_todo sends bare YYYY-MM-DD on calendar_todo" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:request) do |method, path, body = nil, allow: []| + captured = { method: method, path: path, body: body } + { "id" => 12 } + end + + client.create_todo(title: "Buy milk", starts_at: Date.new(2026, 4, 19)) + + assert_equal :post, captured[:method] + assert_equal "/calendar/todos.json", captured[:path] + assert_equal( + { "calendar_todo" => { title: "Buy milk", starts_at: "2026-04-19" } }, + captured[:body] + ) + end + + test "update_todo patches official todos.json path and omits empty fields" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:request) do |method, path, body = nil, allow: []| + captured = { method: method, path: path, body: body } + {} + end + + client.update_todo("77", title: "Renamed") + + assert_equal :patch, captured[:method] + assert_equal "/calendar/todos/77.json", captured[:path] + assert_equal({ "calendar_todo" => { title: "Renamed" } }, captured[:body]) + end + + test "delete_todo uses .json suffix" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:request) do |method, path, body = nil, allow: []| + captured = { method: method, path: path } + {} + end + + client.delete_todo("9") + + assert_equal :delete, captured[:method] + assert_equal "/calendar/todos/9.json", captured[:path] + end + + test "create_timed_calendar_event_form posts /calendar/events.json with set_time_zone" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:form_request) do |method, path, pairs| + captured = { method: method, path: path, pairs: pairs } + { code: 201, body: { "id" => 55 }.to_json, location: nil, unauthorized: false, success: true } + end + + zone = ActiveSupport::TimeZone["America/Los_Angeles"] + id = client.create_timed_calendar_event_form( + calendar_id: "42", + title: "Focus", + local_start: zone.local(2026, 4, 13, 14, 0), + local_end: zone.local(2026, 4, 13, 15, 0), + time_zone: "America/Los_Angeles" + ) + + assert_equal "55", id + assert_equal :post, captured[:method] + assert_equal "/calendar/events.json", captured[:path] + assert_includes captured[:pairs], [ "calendar_event[set_time_zone]", "1" ] + assert_includes captured[:pairs], [ "calendar_event[starts_at_time_zone_name]", "America/Los_Angeles" ] + assert_includes captured[:pairs], [ "calendar_event[starts_at]", "2026-04-13" ] + assert_includes captured[:pairs], [ "calendar_event[starts_at_time]", "14:00:00" ] + end + + test "create_timed_calendar_event_form falls back to redirect id" do + client = HeyClient.new(@user) + client.define_singleton_method(:form_request) do |_method, _path, _pairs| + { code: 302, body: "", location: "/calendar/events/99", unauthorized: false, success: true } + end + + zone = ActiveSupport::TimeZone["UTC"] + id = client.create_timed_calendar_event_form( + calendar_id: "1", + title: "X", + local_start: zone.local(2026, 4, 13, 10, 0), + local_end: zone.local(2026, 4, 13, 11, 0), + time_zone: "UTC" + ) + + assert_equal "99", id + end + + test "update_calendar_event patches official events.json path" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:form_request) do |method, path, pairs| + captured = { method: method, path: path, pairs: pairs } + { code: 200, body: { "id" => 8 }.to_json, location: nil, unauthorized: false, success: true } + end + + zone = ActiveSupport::TimeZone["UTC"] + id = client.update_calendar_event( + calendar_id: "3", + event_id: "8", + title: "Meet", + starts_at: zone.local(2026, 4, 13, 9, 0), + ends_at: zone.local(2026, 4, 13, 10, 0), + all_day: false, + time_zone: "UTC" + ) + + assert_equal "8", id + assert_equal :patch, captured[:method] + assert_equal "/calendar/events/8.json", captured[:path] + assert_includes captured[:pairs], [ "calendar_event[set_time_zone]", "1" ] + end + + test "update_calendar_event routes occurrence ids to occurrences endpoint" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:form_request) do |method, path, _pairs| + captured = { method: method, path: path } + { code: 200, body: { "id" => 88 }.to_json, location: nil, unauthorized: false, success: true } + end + + zone = ActiveSupport::TimeZone["UTC"] + client.update_calendar_event( + calendar_id: "3", + event_id: "88:2026-04-15", + title: "Weekly", + starts_at: zone.local(2026, 4, 15, 9, 0), + ends_at: zone.local(2026, 4, 15, 10, 0), + all_day: false, + time_zone: "UTC" + ) + + assert_equal "/calendar/events/88/occurrences/2026-04-15.json", captured[:path] + end + + test "delete_calendar_event uses official form delete path" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:form_request) do |method, path, _pairs| + captured = { method: method, path: path } + { code: 204, body: "", location: nil, unauthorized: false, success: true } + end + + assert client.delete_calendar_event(calendar_id: "3", event_id: "8") + assert_equal :delete, captured[:method] + assert_equal "/calendar/events/8", captured[:path] + end + + test "start_time_track sends no body and adopts ongoing on 409" do + client = HeyClient.new(@user) + posts = [] + client.define_singleton_method(:request) do |method, path, body = nil, allow: []| + if method == :post + posts << { path: path, body: body, allow: allow } + client.instance_variable_set(:@last_status_code, 409) + return nil + end + { "id" => 321 } + end + + result = client.start_time_track + + assert_equal 1, posts.size + assert_equal "/calendar/ongoing_time_track.json", posts[0][:path] + assert_nil posts[0][:body] + assert_includes posts[0][:allow], 409 + assert_equal 321, result["id"] + end + + test "stop_time_track wraps ends_at under calendar_time_track" do + client = HeyClient.new(@user) + captured = nil + client.define_singleton_method(:request) do |method, path, body = nil, allow: []| + captured = { method: method, path: path, body: body } + {} + end + + client.stop_time_track("44", category_title: "Focus work") + + assert_equal :put, captured[:method] + assert_equal "/calendar/time_tracks/44.json", captured[:path] + assert_equal "Focus work", captured[:body].dig("calendar_time_track", :category_title) + assert captured[:body].dig("calendar_time_track", :ends_at).present? + end + + test "email box methods use canonical laterbox asidebox trailbox paths" do + client = HeyClient.new(@user) + paths = [] + client.define_singleton_method(:get) do |path, allow: []| + paths << path + { "postings" => [] } + end + + client.reply_later + client.set_aside + client.paper_trail + + assert_equal %w[/laterbox.json /asidebox.json /trailbox.json], paths + end + + test "fetch_box follows Link rel=next when next_history_url is absent" do + client = HeyClient.new(@user) + paths = [] + client.define_singleton_method(:get) do |path, allow: []| + paths << path + if path == "/laterbox.json" + client.instance_variable_set(:@last_link_header, '; rel="next"') + { "postings" => [ { "id" => 1, "kind" => "topic" } ] } + else + client.instance_variable_set(:@last_link_header, nil) + { "postings" => [ { "id" => 2, "kind" => "topic" } ] } + end + end + + rows = client.reply_later + assert_equal 2, rows.size + assert_includes paths, "/laterbox.json" + assert_includes paths, "/laterbox.json?page=abc" + end + + test "flatten_calendar_period expands occurrence_id into composite external id" do + client = HeyClient.new(@user) + raw = { + "kind" => "week", + "recordings" => { + "Calendar::Event" => [ + { + "id" => 0, + "parent_id" => 88, + "occurrence_id" => "_", + "title" => "Weekly standup", + "starts_at" => "2026-04-15T15:00:00Z", + "ends_at" => "2026-04-15T15:30:00Z", + "calendar" => { "id" => 7 } + } + ] + } + } + + rows = client.flatten_calendar_period(raw) + assert_equal 1, rows.size + assert_equal "88:2026-04-15", rows.first["id"] + assert_equal "7", rows.first["hey_calendar_id"] + end + + test "calendar_week_events fetches /calendar/weeks/:date.json" do + client = HeyClient.new(@user) + paths = [] + client.define_singleton_method(:get) do |path, allow: []| + paths << path + { "kind" => "week", "recordings" => { "Calendar::Event" => [] } } + end + + client.calendar_week_events("2026-04-13") + assert_equal [ "/calendar/weeks/2026-04-13.json" ], paths + end end