class Reminder < ApplicationRecord
belongs_to :user
validates :time_zone, inclusion: { in: ActiveSupport::TimeZone::MAPPING.values }
validates :local_time, presence: true
scope :active, -> { where(active: true) }
scope :due, -> { active.where("next_run_at <= ?", Time.current) }
before_validation :ensure_next_run_at, on: :create
def zone
ActiveSupport::TimeZone[time_zone]
end
def reschedule!
update!(next_run_at: compute_next_run_at(after: zone.now))
end
def compute_next_run_at(after: nil)
reference = after || zone.now
candidate = reference.change(hour: local_time.hour, min: local_time.min, sec: 0)
candidate += 1.day if candidate <= reference
candidate.utc
end
private
def ensure_next_run_at
self.next_run_at ||= compute_next_run_at
end
end
class ReminderScheduler
include Sidekiq::Job
sidekiq_options queue: :scheduling, retry: 3
def perform
Reminder.due.find_each(batch_size: 200) do |reminder|
deliver(reminder)
end
end
private
def deliver(reminder)
reminder.with_advisory_lock("reminder-#{reminder.id}", timeout_seconds: 0) do
reminder.reload
return unless reminder.active? && reminder.next_run_at <= Time.current
ReminderMailer.notify(reminder).deliver_later
reminder.reschedule!
end
end
end
CREATE TABLE reminders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
local_time time NOT NULL,
time_zone varchar NOT NULL,
next_run_at timestamptz NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
-- Only active reminders are polled, so keep the index small and hot.
CREATE INDEX index_reminders_on_due
ON reminders (next_run_at)
WHERE active = true;
CREATE INDEX index_reminders_on_user_id ON reminders (user_id);
This snippet shows how to schedule recurring reminders that fire at the right wall-clock time for each user, regardless of the server's zone or daylight-saving shifts. The core mistake in naive scheduling is storing a user's "9:00 AM" as an absolute UTC instant computed once; when DST changes, that instant drifts to 8:00 or 10:00 local. The fix is to persist the intent (a local time-of-day plus an IANA zone) and recompute the next absolute instant in that zone every time.
In Reminder model, the record stores local_time (a time column read as hour/minute), time_zone (an IANA identifier like America/Sao_Paulo), and next_run_at (a UTC timestamp). The zone helper wraps ActiveSupport::TimeZone, and compute_next_run_at does the real work: it takes "now" in the user's zone, builds a candidate at the requested hour and minute using change, and rolls forward a day if that moment already passed. Crucially it calls .utc only at the very end, so all arithmetic happens in local time where DST is handled correctly by ActiveSupport. The due scope selects rows whose next_run_at has arrived.
Because next_run_at is always derived, a spring-forward day that skips 2:00–3:00 AM won't produce a duplicated or missing fire — the zone object resolves the nearest valid local time. Storing the zone per row rather than globally means each user keeps their own schedule even as they travel or the app serves multiple regions.
In ReminderScheduler job, a periodic worker loads Reminder.due, and for each one uses with_advisory_lock plus a stale re-check to guarantee only one process delivers a given reminder — important when multiple Sidekiq workers poll the same table. After enqueueing delivery it calls reschedule!, which recomputes next_run_at from the same intent so the next occurrence lands on tomorrow's local local_time.
The schema.sql tab shows the backing table: a partial index on next_run_at limited to active rows keeps the due polling query fast even with millions of reminders. The trade-off of this recompute-on-demand approach is a small polling query cost, but it buys correctness across DST, leap scheduling, and per-user zones — far safer than precomputing a long horizon of UTC instants that silently rot.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.