Skip to content

Conversation

@kitallis
Copy link
Member

@kitallis kitallis commented Nov 19, 2025

Changed kickoff_at (datetime) to kickoff_time (time) to make release scheduling based on time of day rather than specific datetime. The scheduled_at for ScheduledRelease is still stored in UTC but is now calculated by combining kickoff_time with the app's configured timezone.

This makes scheduling more intuitive - trains kick off at the same time of day in the app's timezone regardless of DST changes or other timezone complications.

Changes:

  • Migration: Change kickoff_at column from datetime to time
  • Model: Update next_run_at logic to be timezone-aware
  • Model: Update validations to work with time instead of datetime
  • Controller: Update params and config methods
  • Views: Change datetime field to time field
  • Tests: Update all specs to work with new time-based scheduling
  • Helper: Update release_schedule display to show timezone

BREAKING CHANGE: Existing trains with kickoff_at will need to run the migration to convert to kickoff_time

Closes: https://github.com/tramlinehq/site/issues/

Why

Enter the reason/premise for raising this PR.

This addresses

Enter the details of what all this covers, include screenshots, demos, thought processes etc.

Scenarios tested

  • List the scenarios this is tested against.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed scheduling so upcoming release times are computed and shown in the app's configured timezone.
  • Chores

    • Release schedule form now uses a time-only "Kickoff time" input.
    • Release schedule display now shows "Runs every ... at ... (Timezone)" with explicit timezone included.

Changed kickoff_at (datetime) to kickoff_time (time) to make release
scheduling based on time of day rather than specific datetime. The
scheduled_at for ScheduledRelease is still stored in UTC but is now
calculated by combining kickoff_time with the app's configured timezone.

This makes scheduling more intuitive - trains kick off at the same time
of day in the app's timezone regardless of DST changes or other timezone
complications.

Changes:
- Migration: Change kickoff_at column from datetime to time
- Model: Update next_run_at logic to be timezone-aware
- Model: Update validations to work with time instead of datetime
- Controller: Update params and config methods
- Views: Change datetime field to time field
- Tests: Update all specs to work with new time-based scheduling
- Helper: Update release_schedule display to show timezone

BREAKING CHANGE: Existing trains with kickoff_at will need to run the
migration to convert to kickoff_time
@coderabbitai
Copy link

coderabbitai bot commented Nov 19, 2025

Walkthrough

The PR renames Train.kickoff_at (datetime) to kickoff_time (time-of-day) and updates models, controllers, helpers, views, fixtures, and tests to use the new attribute. Train scheduling logic was rewritten to compute timezone-aware kickoff datetimes using the train's app timezone (new kickoff_datetime method) and to derive next_run_at from kickoff_time and scheduled_releases. A reversible migration converts existing kickoff_at datetimes to kickoff_time (time component in app timezone) and removes kickoff_at. Form inputs, params, anonymization whitelist, and test factories/specs were adjusted accordingly.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant UI as UI / Controller
  participant Train as Train model
  participant TZ as App Timezone
  participant SR as ScheduledRelease store

  rect rgba(120,160,200,0.08)
    UI->>Train: create/update with kickoff_time (HH:MM:SS)
    Train->>TZ: kickoff_datetime(reference_time)
    TZ-->>Train: timezone-aware datetime (kickoff datetime)
  end

  rect rgba(120,200,160,0.08)
    Note right of Train: next_run_at calculation
    Train->>SR: query scheduled_releases (ordered)
    SR-->>Train: last_scheduled / releases list
    Train->>Train: compute base_time (last_run_at or kickoff_datetime)
    Train->>Train: add repeat_duration multiples -> next_run_at
    Train-->>UI: return next_run_at (UTC)
  end
Loading

Poem

🐰 A tick, a tock, a daily chime,
I swapped the datetime for a time,
In app timezone I softly land,
Next runs computed with careful hand,
Hopping schedules tidy and fine.

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is largely incomplete. While it provides a good summary of changes and rationale at the top, the required template sections 'Why', 'This addresses', and 'Scenarios tested' are either missing substantive content or contain only placeholder text. The 'Closes' reference is also incomplete ( placeholder). Fill in all required template sections: provide concrete reasoning in 'Why', detailed coverage in 'This addresses' with any supporting details, complete the 'Scenarios tested' checklist with specific test scenarios, and add the correct issue number to the 'Closes' reference.
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'chore: store release schedule without timezone' is partially related to the changeset. It captures the core technical intent (storing schedule without timezone via time-based fields) but is somewhat vague. The PR actually changes kickoff_at to kickoff_time and updates timezone-aware scheduling logic throughout—a significant refactor across multiple layers. A more specific title like 'refactor: use time-of-day for release scheduling' would better convey the scope.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/update-train-scheduling-01Sr3QquxMsi23HSFSzwEVSb

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7f1d746 and ed2eb57.

📒 Files selected for processing (1)
  • app/models/train.rb (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/models/train.rb (2)
app/components/event_timeline_component.rb (1)
  • tz (73-73)
app/models/build_artifact.rb (1)
  • app (60-62)
🪛 GitHub Check: lint
app/models/train.rb

[failure] 248-248:
Style/StringLiteralsInInterpolation: Prefer double-quoted strings inside interpolations.

🔇 Additional comments (1)
app/models/train.rb (1)

19-19: LGTM: Attribute migration and supporting changes are correct

The migration from kickoff_at to kickoff_time is implemented correctly across these segments:

  • Schema documentation updated
  • Validations and callbacks use kickoff_time_changed? and presence checks
  • automatic? correctly requires kickoff_time.present?
  • kickoff_datetime properly converts the time-of-day to a timezone-aware datetime by combining with the reference date in the app's timezone
  • runnable? defensive nil check and first-run logic are good improvements
  • last_run_at simplification is cleaner
  • valid_schedule error messages correctly reference kickoff_time

Also applies to: 119-119, 149-149, 216-216, 232-240, 242-249, 599-604


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kitallis kitallis changed the title Update train model release scheduling configuration Store release schedule without timezone Nov 19, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/controllers/trains_controller.rb (1)

199-205: Potential crash + param mix-up in parsed_train_update_params when version_bump_file_paths is nil

parsed_train_update_params currently does:

.merge(version_bump_file_paths: train_params.fetch(:version_bump_file_paths, "").safe_csv_parse(coerce_string: true))

Two issues:

  1. If :version_bump_file_paths is present but explicitly nil, fetch returns nil and .safe_csv_parse will raise NoMethodError.
  2. Using train_params in the update path is confusing and may diverge from train_update_params's strong-parameter rules over time.

You can mirror the create path and fix the nil case with:

-      .merge(version_bump_file_paths: train_params.fetch(:version_bump_file_paths, "").safe_csv_parse(coerce_string: true))
+      .merge(
+        version_bump_file_paths: train_update_params
+          .fetch(:version_bump_file_paths, "")
+          &.safe_csv_parse(coerce_string: true)
+      )

This keeps behavior consistent between create/update and avoids crashes when the field is present but blank.

🧹 Nitpick comments (4)
app/helpers/trains_helper.rb (1)

2-8: Consider I18n + small guard around kickoff_time formatting

The new "Runs every #{duration} at #{time} (#{timezone})" message is clear and surfaces the timezone, which matches the PR intent. To make this a bit more robust and localizable you might:

  • Use I18n/l helpers for the time instead of hardcoded strftime, and
  • Optionally guard against a missing kickoff_time to avoid NoMethodError on misconfigured records.

For example:

-      time = train.kickoff_time.strftime("%I:%M%p")
+      time = train.kickoff_time && l(train.kickoff_time, format: :short)

Not strictly required, but improves resilience and localization.

spec/factories/trains.rb (1)

50-54: Align factory time arithmetic with Rails style (and lint)

kickoff_time { (Time.current + 2.hours).strftime("%H:%M:%S") } works but triggers Rails/DurationArithmetic and is slightly less idiomatic.

You can keep the same behavior and satisfy the cop with:

-      kickoff_time { (Time.current + 2.hours).strftime("%H:%M:%S") }
+      kickoff_time { 2.hours.from_now.strftime("%H:%M:%S") }

This also matches the previous kickoff_at { 2.hours.from_now } style.

spec/jobs/schedule_train_releases_job_spec.rb (1)

7-17: Timezone-aware setup looks good; consider tightening time arithmetic for clarity

The updated specs correctly:

  • Use the app’s timezone to construct first_scheduled, then store it as UTC, and
  • Assert that the next scheduled release is first_scheduled + train.repeat_duration, which exercises the new next_run_at logic.

A couple of small polish points for robustness and readability:

  1. Prefer idiomatic duration helpers over Time.current + 2.hours:
-    future_time = (Time.current + 2.hours).strftime("%H:%M:%S")
+    future_time = 2.hours.from_now.strftime("%H:%M:%S")
  1. Avoid calling Time.current twice with potentially different zones when building future_scheduled:
-    future_scheduled = Time.current.in_time_zone(tz).change(hour: Time.current.hour + 2).utc
+    now_in_tz = Time.current.in_time_zone(tz)
+    future_scheduled = now_in_tz.change(hour: now_in_tz.hour + 2).utc

Optionally, you could also travel_to a fixed base time in this example for extra determinism, but it’s not strictly necessary given the current assertions.

Also applies to: 30-37

spec/models/train_spec.rb (1)

200-206: Tidy up time arithmetic in update-spec to satisfy lint and match Rails idioms

The behavior in this example is correct, but future_time = (Time.current + 2.hours).strftime("%H:%M:%S") is flagged by Rails/DurationArithmetic and is slightly non-idiomatic.

You can keep the same semantics and appease the linter by using:

-      future_time = (Time.current + 2.hours).strftime("%H:%M:%S")
+      future_time = 2.hours.from_now.strftime("%H:%M:%S")

The subsequent expectations comparing scheduled_at in the app timezone with train.kickoff_time remain valid.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b05ff66 and 7f1d746.

📒 Files selected for processing (12)
  • app/components/release_list_component.rb (1 hunks)
  • app/controllers/trains_controller.rb (3 hunks)
  • app/helpers/trains_helper.rb (1 hunks)
  • app/models/train.rb (5 hunks)
  • app/views/trains/_release_schedule_form.html.erb (1 hunks)
  • db/data/20240305124235_connect_scheduled_releases_with_release.rb (1 hunks)
  • db/migrate/20251118165553_change_kickoff_at_to_kickoff_time.rb (1 hunks)
  • lib/tasks/anonymize.rake (1 hunks)
  • spec/components/scheduled_train_component_spec.rb (8 hunks)
  • spec/factories/trains.rb (1 hunks)
  • spec/jobs/schedule_train_releases_job_spec.rb (2 hunks)
  • spec/models/train_spec.rb (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
spec/factories/trains.rb (1)
app/javascript/controllers/countdown_controller.js (1)
  • hours (33-33)
spec/jobs/schedule_train_releases_job_spec.rb (3)
app/components/event_timeline_component.rb (1)
  • tz (73-73)
db/migrate/20230803115802_add_scheduled_releases_attempts_for_train.rb (2)
  • change (1-13)
  • change (2-12)
app/jobs/schedule_train_releases_job.rb (1)
  • perform (4-10)
app/components/release_list_component.rb (1)
app/models/train.rb (1)
  • next_run_at (219-252)
spec/models/train_spec.rb (3)
app/models/train.rb (1)
  • next_run_at (219-252)
db/migrate/20230802131756_add_train_schedule.rb (2)
  • change (1-10)
  • change (2-9)
db/migrate/20230803115802_add_scheduled_releases_attempts_for_train.rb (2)
  • change (1-13)
  • change (2-12)
app/models/train.rb (3)
app/components/scheduled_train_component.rb (1)
  • next_run_at (99-102)
app/components/event_timeline_component.rb (1)
  • tz (73-73)
app/models/build_artifact.rb (1)
  • app (60-62)
🪛 GitHub Actions: CI
db/migrate/20251118165553_change_kickoff_at_to_kickoff_time.rb

[error] 1-1: Pending migration detected. Run 'bin/rails db:migrate' to apply migrations.

🪛 GitHub Check: lint
spec/factories/trains.rb

[failure] 52-52:
Rails/DurationArithmetic: Do not add or subtract duration.

spec/models/train_spec.rb

[failure] 200-200:
Rails/DurationArithmetic: Do not add or subtract duration.

app/models/train.rb

[failure] 243-243:
Style/StringLiteralsInInterpolation: Prefer double-quoted strings inside interpolations.

🔇 Additional comments (7)
lib/tasks/anonymize.rake (1)

45-59: kickoff_time whitelist matches new schema

Updating the trains whitelist to use "kickoff_time" instead of "kickoff_at" is consistent with the migration and preserves the scheduling data shape in anonymized dumps; no functional issues here.

app/components/release_list_component.rb (1)

145-155: Timezone-aware upcoming-release message looks correct

Using train.next_run_at.in_time_zone(train.app.timezone).to_fs(:short) here aligns the UI with the new kickoff_time semantics and keeps the “Upcoming release” copy in the app’s local timezone. Given the train.automatic? guard and schedule validations, relying on next_run_at being present in this branch is reasonable.

db/data/20240305124235_connect_scheduled_releases_with_release.rb (1)

4-7: kickoff_time filter is consistent (though currently unreachable)

Updating the automatic-train scope to .where.not(kickoff_time: nil) matches the new schema. Given the early return in up, this migration still does nothing at runtime, but the dormant logic is now consistent if it’s ever re-enabled.

spec/components/scheduled_train_component_spec.rb (1)

44-44: Kickoff time test data wiring looks consistent with new time-of-day semantics

Using kickoff_time: (creation_time + 1.hour).strftime("%H:%M:%S") in these CalVer scenarios matches the new kickoff_time (HH:MM:SS) contract and keeps the relative schedule logic intact with the updated next_run_at implementation. No issues from a correctness standpoint.

Also applies to: 69-69, 96-96, 121-121, 174-174, 199-199, 226-226, 251-251

app/controllers/trains_controller.rb (1)

95-113: Controller wiring for kickoff_time and release schedule config looks correct

Allowing :kickoff_time in both create/update params and threading it through release_schedule_config_params / release_schedule_config keeps the controller aligned with the new time-of-day–based scheduling logic. Passing through the raw value here and letting the model/DB handle casting is appropriate.

Also applies to: 158-172, 246-258

app/views/trains/_release_schedule_form.html.erb (1)

14-17: Form changes correctly reflect kickoff_time and new Stimulus target

Switching to labeled_time_field :kickoff_time with the "Kickoff time" label and updating the Stimulus target to "kickoffTime" are consistent with the new time-of-day scheduling model and the controller’s permitted params. This keeps the UI aligned with the backend behavior.

spec/models/train_spec.rb (1)

100-107: next_run_at and activation specs accurately capture the new scheduling semantics

The updated specs around activate! and #next_run_at cover the key behaviors of the new time-based scheduling:

  • First scheduled release aligns with kickoff_time in the app’s timezone.
  • When no releases exist, next_run_at is the next occurrence of kickoff_time (today or rolled forward by repeat_duration).
  • When releases exist, next_run_at advances from the last scheduled_at, including when that last run is far in the past.

The use of in_time_zone(train.app.timezone).strftime("%H:%M:%S") for assertions is consistent with the Train implementation and ensures the tests are robust to timezone handling. No functional issues here.

Also applies to: 111-172

Comment on lines 219 to 252
def next_run_at
return unless automatic?

base_time = last_run_at
now = Time.current
# Get the app's timezone
tz = app.timezone

return base_time if now < base_time
# If we have previous scheduled releases, calculate next occurrence from last scheduled time
if scheduled_releases.any?
base_time = scheduled_releases.last.scheduled_at
next_time = base_time + repeat_duration

time_difference = now - base_time
elapsed_durations = (time_difference / repeat_duration.to_i).ceil
base_time + (repeat_duration.to_i * elapsed_durations)
# Keep adding repeat_duration until we get a future time
while next_time <= Time.current
next_time += repeat_duration
end

return next_time.utc
end

# First time: combine kickoff_time with today's date in app timezone
now_in_tz = Time.current.in_time_zone(tz)

# Parse kickoff_time as a time today in the app's timezone
# kickoff_time is stored as HH:MM:SS
kickoff_today = tz.parse("#{now_in_tz.to_date} #{kickoff_time.strftime('%H:%M:%S')}")

# If kickoff_today already passed, start from the next occurrence
next_time = kickoff_today
while next_time <= now_in_tz
next_time += repeat_duration
end

next_time.utc
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Ensure recurring runs stay at the same local time across DST + address lint hint

The new next_run_at correctly uses app.timezone for the first occurrence, but when there are existing scheduled_releases it does:

base_time = scheduled_releases.last.scheduled_at
next_time = base_time + repeat_duration

while next_time <= Time.current
  next_time += repeat_duration
end

Because scheduled_at is stored in UTC, subsequent runs are computed purely in UTC. On DST transitions this will shift the wall-clock time (e.g., daily runs may move by ±1 hour), which conflicts with the stated goal of “same local time regardless of DST”.

You can keep all recurrence math in the app’s timezone (for both first and subsequent runs) and only convert to UTC at the end. This also lets you reuse the same now_in_tz and fixes the string-literal-in-interpolation lint by switching to double quotes:

   def next_run_at
     return unless automatic?
 
-    # Get the app's timezone
-    tz = app.timezone
-
-    # If we have previous scheduled releases, calculate next occurrence from last scheduled time
-    if scheduled_releases.any?
-      base_time = scheduled_releases.last.scheduled_at
-      next_time = base_time + repeat_duration
-
-      # Keep adding repeat_duration until we get a future time
-      while next_time <= Time.current
-        next_time += repeat_duration
-      end
-
-      return next_time.utc
-    end
-
-    # First time: combine kickoff_time with today's date in app timezone
-    now_in_tz = Time.current.in_time_zone(tz)
-
-    # Parse kickoff_time as a time today in the app's timezone
-    # kickoff_time is stored as HH:MM:SS
-    kickoff_today = tz.parse("#{now_in_tz.to_date} #{kickoff_time.strftime('%H:%M:%S')}")
-
-    # If kickoff_today already passed, start from the next occurrence
-    next_time = kickoff_today
-    while next_time <= now_in_tz
-      next_time += repeat_duration
-    end
-
-    next_time.utc
+    tz = app.timezone
+    now_in_tz = Time.current.in_time_zone(tz)
+
+    if scheduled_releases.any?
+      # Work in app timezone so recurring runs stay at the same local time
+      base_time_in_tz = scheduled_releases.last.scheduled_at.in_time_zone(tz)
+      next_time_in_tz = base_time_in_tz + repeat_duration
+
+      while next_time_in_tz <= now_in_tz
+        next_time_in_tz += repeat_duration
+      end
+
+      return next_time_in_tz.utc
+    end
+
+    # First time: combine kickoff_time with today's date in app timezone
+    kickoff_today = tz.parse("#{now_in_tz.to_date} #{kickoff_time.strftime("%H:%M:%S")}")
+
+    next_time_in_tz = kickoff_today
+    while next_time_in_tz <= now_in_tz
+      next_time_in_tz += repeat_duration
+    end
+
+    next_time_in_tz.utc
   end

This keeps all scheduled runs anchored to the same local wall-clock time in each app’s timezone and also satisfies the StringLiteralsInInterpolation lint.


I'll verify the review comment by examining the actual code and understanding the timezone/DST concerns.


🏁 Script executed:

#!/bin/bash
# Examine the current next_run_at implementation
cat app/models/train.rb | sed -n '219,252p'

Length of output: 1030


🏁 Script executed:

#!/bin/bash
# Check the full context around the method and related timezone logic
cat app/models/train.rb | sed -n '200,260p'

Length of output: 1655


🏁 Script executed:

#!/bin/bash
# Find related timezone and scheduling code
rg -n "scheduled_releases|kickoff_time|repeat_duration" app/models/train.rb -B2 -A2

Length of output: 4059


🏁 Script executed:

#!/bin/bash
# Check if there are tests for next_run_at behavior
fd "train" app/models --type f | xargs rg -l "next_run_at" 2>/dev/null | head -5

Length of output: 19


🏁 Script executed:

#!/bin/bash
# Find test files for train model
fd ".*train.*test\|.*train.*spec" --type f | head -10

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the Migration or schema to confirm scheduled_at storage
rg -n "scheduled_at" --type ruby app/models app/migrations -B2 -A2 | head -20

Length of output: 1078


🏁 Script executed:

#!/bin/bash
# Verify the Ruby lint rule about string literals in interpolation
rg -n "StringLiteralsInInterpolation" . --type ruby 2>/dev/null | head -5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check repeat_duration data type and how it's used
rg -n "repeat_duration" app/models/train.rb -B3 -A3

Length of output: 2907


🏁 Script executed:

#!/bin/bash
# Check Time.current vs now_in_tz usage for consistency
rg -n "Time\.current|now_in_tz" app/models/train.rb -B1 -A1

Length of output: 544


🏁 Script executed:

#!/bin/bash
# Verify automatic? method definition and its dependencies
rg -n "def automatic\?|kickoff_time\.|repeat_duration\." app/models/train.rb -B1 -A2 | head -30

Length of output: 1127


🏁 Script executed:

#!/bin/bash
# Check if there's a timezone attribute on the app model
rg -n "def timezone|timezone" app/models/app.rb -B1 -A2 | head -20

Length of output: 240


🏁 Script executed:

#!/bin/bash
# Look for DST-related tests or comments in the codebase
rg -i "dst|daylight.*saving" app/models app/spec -C3 2>/dev/null

Length of output: 5199


🏁 Script executed:

#!/bin/bash
# Examine the scheduled_releases association and scheduled_at attribute
rg -n "has_many.*scheduled_releases|class ScheduledRelease" app/models/train.rb app/models/scheduled_release.rb -B2 -A5

Length of output: 1083


🏁 Script executed:

#!/bin/bash
# Check the ScheduledRelease model structure
test -f app/models/scheduled_release.rb && cat app/models/scheduled_release.rb | head -50

Length of output: 1358


🏁 Script executed:

#!/bin/bash
# Verify if scheduled_at is a datetime stored in UTC
rg -n "scheduled_at" app/models/scheduled_release.rb -B2 -A2

Length of output: 997


🏁 Script executed:

#!/bin/bash
# Look for any existing timezone conversion in the code
rg -n "in_time_zone|\.utc|\.local" app/models/train.rb -B1 -A1 | head -40

Length of output: 222


🏁 Script executed:

#!/bin/bash
# Check for Rails version and available timezone utilities
test -f Gemfile && grep -i "rails\|activesupport" Gemfile | head -5

Length of output: 144


🏁 Script executed:

#!/bin/bash
# Verify lint configuration
test -f .rubocop.yml && cat .rubocop.yml | grep -A10 "StringLiteralsInInterpolation" 2>/dev/null || echo "No .rubocop.yml or rule not found"

Length of output: 33


🏁 Script executed:

#!/bin/bash
# Check the actual diff to understand what changed
git diff HEAD^ app/models/train.rb | head -100

Length of output: 4062


🏁 Script executed:

#!/bin/bash
# Review the validation and callback for kickoff_time changes
rg -n "valid_schedule|schedule_release\!" app/models/train.rb -B3 -A5

Length of output: 130


🏁 Script executed:

#!/bin/bash
# Check if runnable? method uses next_run_at
rg -n "def runnable\?" app/models/train.rb -B2 -A10

Length of output: 312


🏁 Script executed:

#!/bin/bash
# Look for any existing DST handling or timezone-aware scheduling
rg -n "DST|next_occurrence|scheduled.*time" app/models/ -i --type ruby | head -20

Length of output: 1550


🏁 Script executed:

#!/bin/bash
# Verify the exact timezone attribute structure
rg -n "timezone" app/models/ -C2 --type ruby | grep -E "(attr|column|def)" | head -15

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the parsing logic to ensure tz.parse is valid
rg -n "tz\.parse|ActiveSupport::TimeZone" app/models/train.rb -B1 -A1

Length of output: 143


🏁 Script executed:

#!/bin/bash
# Look at the proposed change more carefully - verify in_time_zone chaining
ast-grep --pattern 'in_time_zone($_)'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Double-check if Time values stored in DB are UTC by default in Rails
rg -n "create_table.*scheduled_release\|t\.datetime" db/migrate -B5 -A10 | head -30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Verify repeat_duration is stored as a duration/interval
rg -n "repeat_duration" app/models/train.rb | head -5

Length of output: 356


🏁 Script executed:

#!/bin/bash
# Check how repeat_duration is used in addition operations
rg -n "repeat_duration" app/models/train.rb -A5 -B1 | grep -E "(\+|-|\*|repeat)" | head -10

Length of output: 680


🏁 Script executed:

#!/bin/bash
# Check if there are any existing issues or TODOs related to timezone handling
rg -n "TODO|FIXME|BUG|XXX" app/models/train.rb | grep -i "time\|zone\|dst"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Verify the exact line numbers and check if code matches the review
sed -n '219,252p' app/models/train.rb | wc -l

Length of output: 2


🏁 Script executed:

#!/bin/bash
# Get more context - check the entire next_run_at method
ast-grep --pattern 'def next_run_at { $$$ }'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Last validation - verify strftime usage with quotes
grep -n "strftime.*%" app/models/train.rb | head -5

Length of output: 93


🏁 Script executed:

#!/bin/bash
# Check if the current branch/PR actually contains the method in question
git log --oneline -1 && git diff --name-only HEAD~1 HEAD 2>/dev/null | grep train.rb

Length of output: 97


🏁 Script executed:

#!/bin/bash
# See if the original comment hints at what was before this change
git show HEAD:app/models/train.rb 2>/dev/null | sed -n '219,252p' || echo "Cannot access previous version"

Length of output: 1030


🏁 Script executed:

#!/bin/bash
# Inspect the full train.rb file structure
wc -l app/models/train.rb

Length of output: 23


🏁 Script executed:

#!/bin/bash
# Ensure file exists
test -f app/models/train.rb && echo "File exists" || echo "File does not exist"

Length of output: 11


🏁 Script executed:

#!/bin/bash
# List the app/models directory
ls -la app/models/ | head -20

Length of output: 1287


🏁 Script executed:

#!/bin/bash
# Check if this is the right repo structure
pwd && ls -la | head -20

Length of output: 1222


🏁 Script executed:

#!/bin/bash
# Verify git status
git status --short 2>/dev/null | head -10

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get repo info
git remote -v 2>/dev/null || echo "No git remote"

Length of output: 126


🏁 Script executed:

#!/bin/bash
# Check branch info
git branch -v 2>/dev/null || echo "Not a git repo or no branches"

Length of output: 172


🏁 Script executed:

#!/bin/bash
# Try to see recent changes
git log --oneline -5 2>/dev/null || echo "Cannot access git log"

Length of output: 334


🏁 Script executed:

#!/bin/bash
# List all Ruby model files
find app/models -name "*.rb" -type f 2>/dev/null

Length of output: 2898


🏁 Script executed:

#!/bin/bash
# Final check: examine the actual code structure
cat app/models/train.rb 2>/dev/null | head -50 || echo "Cannot read file"

Length of output: 3442


🏁 Script executed:

#!/bin/bash
# Alternative: use find to locate the file
find . -name "train.rb" -path "*/models/*" 2>/dev/null

Length of output: 21


🏁 Script executed:

#!/bin/bash
# Check current directory structure
ls -la

Length of output: 3149


🏁 Script executed:

#!/bin/bash
# Check if we have access to app directory
test -d app && ls -la app/ || echo "No app directory"

Length of output: 930


🏁 Script executed:

#!/bin/bash
# Final sanity check
pwd && find . -maxdepth 3 -type d -name models 2>/dev/null

Length of output: 45


🏁 Script executed:

#!/bin/bash
# List everything we can find
ls -R . | head -100 || echo "Cannot list"

Length of output: 1101


🏁 Script executed:

#!/bin/bash
# Check environment
env | grep -i ruby || echo "Check failed"

Length of output: 12


🏁 Script executed:

#!/bin/bash
# Simple directory listing
ls 2>/dev/null || pwd

Length of output: 281


Recurring runs will drift from kickoff time across DST transitions; consolidate timezone handling and fix lint issue

The new next_run_at correctly uses app.timezone for the first occurrence, but when scheduled_releases exist, it performs all recurrence math in UTC:

base_time = scheduled_releases.last.scheduled_at       # UTC datetime
next_time = base_time + repeat_duration                # UTC math
while next_time <= Time.current                         # UTC comparison

Because scheduled_at is stored as UTC, subsequent runs bypass timezone logic. On DST transitions this shifts the wall-clock time (e.g., a daily 9 AM run may become 10 AM after spring forward), contradicting the stated goal of "same local time regardless of DST".

Convert the scheduled_releases branch to use app timezone for all recurrence math and only convert to UTC at the end:

   def next_run_at
     return unless automatic?
 
-    # Get the app's timezone
-    tz = app.timezone
-
-    # If we have previous scheduled releases, calculate next occurrence from last scheduled time
-    if scheduled_releases.any?
-      base_time = scheduled_releases.last.scheduled_at
-      next_time = base_time + repeat_duration
-
-      # Keep adding repeat_duration until we get a future time
-      while next_time <= Time.current
-        next_time += repeat_duration
-      end
-
-      return next_time.utc
-    end
-
-    # First time: combine kickoff_time with today's date in app timezone
-    now_in_tz = Time.current.in_time_zone(tz)
-
-    # Parse kickoff_time as a time today in the app's timezone
-    # kickoff_time is stored as HH:MM:SS
-    kickoff_today = tz.parse("#{now_in_tz.to_date} #{kickoff_time.strftime('%H:%M:%S')}")
-
-    # If kickoff_today already passed, start from the next occurrence
-    next_time = kickoff_today
-    while next_time <= now_in_tz
-      next_time += repeat_duration
-    end
-
-    next_time.utc
+    tz = app.timezone
+    now_in_tz = Time.current.in_time_zone(tz)
+
+    if scheduled_releases.any?
+      # Work in app timezone so recurring runs stay at the same local time
+      base_time_in_tz = scheduled_releases.last.scheduled_at.in_time_zone(tz)
+      next_time_in_tz = base_time_in_tz + repeat_duration
+
+      while next_time_in_tz <= now_in_tz
+        next_time_in_tz += repeat_duration
+      end
+
+      return next_time_in_tz.utc
+    end
+
+    # First time: combine kickoff_time with today's date in app timezone
+    kickoff_today = tz.parse("#{now_in_tz.to_date} #{kickoff_time.strftime("%H:%M:%S")}")
+
+    next_time_in_tz = kickoff_today
+    while next_time_in_tz <= now_in_tz
+      next_time_in_tz += repeat_duration
+    end
+
+    next_time_in_tz.utc
   end

This keeps all scheduled runs anchored to the same local wall-clock time in the app's timezone and also addresses the StringLiteralsInInterpolation lint hint by using double quotes.

🧰 Tools
🪛 GitHub Check: lint

[failure] 243-243:
Style/StringLiteralsInInterpolation: Prefer double-quoted strings inside interpolations.

🤖 Prompt for AI Agents
In app/models/train.rb around lines 219-252, the scheduled_releases branch does
recurrence math in UTC which causes DST drift and the interpolation uses
single-quoted string literals; convert the last scheduled_at into the app
timezone and do all recurrence additions and comparisons in that Time.zone (e.g.
base_time_in_tz = scheduled_releases.last.scheduled_at.in_time_zone(tz)), loop
adding repeat_duration to that time until it's after now_in_tz (use now_in_tz =
Time.current.in_time_zone(tz)), then convert the resulting TimeWithZone to UTC
for return; also change any string literal in interpolation (e.g. the
kickoff_time.strftime format) to use double quotes to satisfy
StringLiteralsInInterpolation.

Comment on lines +10 to +21
reversible do |dir|
dir.up do
Train.reset_column_information
Train.find_each do |train|
next if train.kickoff_at.blank?

# Convert kickoff_at (UTC) to app's timezone and extract time component
time_in_app_tz = train.kickoff_at.in_time_zone(train.app.timezone)
train.update_column(:kickoff_time, time_in_app_tz.strftime("%H:%M:%S"))
end
end
end
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Down-migration parsing bug for kickoff_time + minor migration cleanup

Two points here:

  1. Down path: malformed parse string (bug)
    In the down direction you build the datetime with:

    kickoff_today = tz.parse("#{now.to_date} #{train.kickoff_time}")

    For a time column, train.kickoff_time will typically stringify with its own date and zone, so the resulting string is ambiguous (e.g., "2025-11-19 2000-01-01 11:00:00 UTC"), and tz.parse may misinterpret or fail. You already do the right thing in Train#next_run_at by formatting only the time portion.

    I’d strongly recommend mirroring that here:

  •      kickoff_today = tz.parse("#{now.to_date} #{train.kickoff_time}")
    
  •      kickoff_today = tz.parse("#{now.to_date} #{train.kickoff_time.strftime("%H:%M:%S")}")
    
    
    This makes the down migration deterministic and matches the semantics used in the model.
    
    
  1. Optional: avoid N+1 on app lookups
    Both up and down use Train.find_each and then hit train.app.timezone, which will issue an extra query per train. If you expect a large trains table, you can cheaply remove this N+1:

  •    Train.reset_column_information
    
  •    Train.find_each do |train|
    
  •    Train.reset_column_information
    
  •    Train.includes(:app).find_each do |train|
    
    
    (Same pattern in the down block.)
    
    This keeps behavior identical but scales better on larger datasets.
    
    
    
    
    
    
    

Also applies to: 34-52


🏁 Script executed:

#!/bin/bash
# Retrieve the full migration file to inspect down block
find . -name "20251118165553_change_kickoff_at_to_kickoff_time.rb" -type f -exec cat {} \;

Length of output: 1764


🏁 Script executed:

#!/bin/bash
# Find Train model to check next_run_at method
fd -e rb -path "*/models/*" -name "*train*" -type f | head -20

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Search for next_run_at method in Train model
rg -n "def next_run_at" --type=rb -A 5

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Check how time columns are typically used in migrations/models
rg -n "\.strftime\(" --type=rb -C 2 | grep -A 2 -B 2 "time"

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Find Train model files
find . -name "*train*" -type f | grep -E "models?/" | head -10

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Search for next_run_at method without type flag
rg -n "def next_run_at" -A 5

Length of output: 649


🏁 Script executed:

#!/bin/bash
# Search for how time columns are used in codebase
rg -n "kickoff_time" -C 3

Length of output: 20885


🏁 Script executed:

#!/bin/bash
# Look for strftime usage with time values to understand pattern
rg "strftime.*%H:%M:%S" -C 2

Length of output: 8379


Down-migration parsing bug for kickoff_time + minor migration cleanup

Two points here:

  1. Down path: malformed parse string (bug)
    In the down direction you build the datetime with:

    kickoff_today = tz.parse("#{now.to_date} #{train.kickoff_time}")

    For a time column, train.kickoff_time when interpolated lacks explicit formatting. The codebase consistently uses strftime("%H:%M:%S") when parsing—as seen in Train#next_run_at (line 243) and throughout specs. Update to match this pattern:

  •      kickoff_today = tz.parse("#{now.to_date} #{train.kickoff_time}")
    
  •      kickoff_today = tz.parse("#{now.to_date} #{train.kickoff_time.strftime("%H:%M:%S")}")
    
    
    This ensures the down migration is deterministic and consistent with the model logic.
    
    
  1. Optional: avoid N+1 on app lookups
    Both up and down use Train.find_each and then hit train.app.timezone, which issues an extra query per train. Add eager loading:

  •    Train.reset_column_information
    
  •    Train.find_each do |train|
    
  •    Train.reset_column_information
    
  •    Train.includes(:app).find_each do |train|
    
    
    (Same pattern in the down block.)
    
    
🤖 Prompt for AI Agents
In db/migrate/20251118165553_change_kickoff_at_to_kickoff_time.rb around lines
10 to 21, the down migration builds a datetime using an unformatted kickoff_time
string which can parse inconsistently; change the interpolation to use the same
formatting pattern used elsewhere (strftime("%H:%M:%S")) when reconstructing the
time so parsing is deterministic, and to avoid N+1 queries preload apps for both
up and down (use a query that eager-loads the associated app before iterating,
e.g., include the app association) so train.app.timezone does not hit the DB per
record.

@kitallis kitallis changed the title Store release schedule without timezone chore: store release schedule without timezone Nov 19, 2025
- Add kickoff_datetime helper method to centralize timezone conversion
- Restore original mathematical approach instead of while loops
- Calculate elapsed durations and jump ahead directly (more efficient)
- Remove redundant timezone math scattered throughout the code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants