I apologize in advance, this is going to be a long question.
Short version:
I have a Meeting model that has a date, start_time, and end_time. These are time objects, which of course are a pain for users to input, so I'm using virtual attributes to accept strings which are parsed by Chronic before save.
I have a plain vanilla rails controller that receives these virtual attributes from the form and passes them along to the model. Here is the controller:
def create
#meeting = #member.meetings.build(params[:meeting])
if #meeting.save
redirect_to member_meetings_path(#member), :notice => "Meeting Added"
else
render :new
end
end
def update
#meeting = #member.meetings.find(params[:id])
if #meeting.update_attributes(params[:meeting])
redirect_to member_meetings_path(#member), :notice => "Meeting Updated"
else
render :new
end
end
I've verified that the controller receives the correct parameters from the form, for instance params[:meeting][:date_string] is set as expected.
Problems:
On create, the date gets set correctly, but the times are assigned to the year 2000, set in UTC, and won't display in local time on the front end.
On update, the date won't update. The times update but stay in UTC for 2000-01-01.
Longer Version
What makes this super bizarre to me is I have decent test coverage indicating all of this works at the model layer.
Here is the model:
# DEPENDENCIES
require 'chronic'
class Meeting < ActiveRecord::Base
# MASS ASSIGNMENT PROTECTION
attr_accessible :name, :location, :description, :contact_id, :member_id, :time_zone,
:date, :start_time, :end_time, :date_string, :start_time_string, :end_time_string
# RELATIONSHIPS
belongs_to :member
belongs_to :contact
# CALLBACKS
before_save :parse_time
# Time IO Formatting
attr_writer :date_string, :start_time_string, :end_time_string
# Display time as string, year optional
def date_string(year=true)
if date
str = "%B %e"
str += ", %Y" if year
date.strftime(str).gsub(' ',' ')
else
""
end
end
# Display time as string, AM/PM optional
def start_time_string(meridian=true)
if start_time
str = "%l:%M"
str += " %p" if meridian
start_time.strftime(str).lstrip
else
""
end
end
# Display time as string, AM/PM optional
def end_time_string(meridian=true)
if end_time
str = "%l:%M"
str += " %p" if meridian
end_time.strftime(str).lstrip
else
""
end
end
# Display Date and Time for Front-End
def time
date.year == Date.today.year ? y = false : y = true
start_time.meridian != end_time.meridian ? m = true : m = false
[date_string(y),'; ',start_time_string(m),' - ',end_time_string].join
end
private
# Time Input Processing, called in `before_save`
def parse_time
set_time_zone
self.date ||= #date_string ? Chronic.parse(#date_string).to_date : Date.today
self.start_time = Chronic.parse #start_time_string, :now => self.date
self.end_time = Chronic.parse #end_time_string, :now => self.date
end
def set_time_zone
if time_zone
Time.zone = time_zone
elsif member && member.time_zone
Time.zone = member.time_zone
end
Chronic.time_class = Time.zone
end
end
Here is the spec. Note that to test the parse_time callback in isolation I'm calling #meeting.send(:parse_time) in these tests whenever I'm not actually creating or updating a record.
require "minitest_helper"
describe Meeting do
before do
#meeting = Meeting.new
end
describe "accepting dates in natural language" do
it "should recognize months and days" do
#meeting.date_string = 'December 17'
#meeting.send(:parse_time)
#meeting.date.must_equal Date.new(Time.now.year,12,17)
end
it "should assume a start time is today" do
#meeting.start_time_string = '1pm'
#meeting.send(:parse_time)
#meeting.start_time.must_equal Time.zone.local(Date.today.year,Date.today.month,Date.today.day, 13,0,0)
end
it "should assume an end time is today" do
#meeting.end_time_string = '3:30'
#meeting.send(:parse_time)
#meeting.end_time.must_equal Time.zone.local(Date.today.year,Date.today.month,Date.today.day, 15,30,0)
end
it "should set start time to the given date" do
#meeting.date = Date.new(Time.now.year,12,1)
#meeting.start_time_string = '4:30 pm'
#meeting.send(:parse_time)
#meeting.start_time.must_equal Time.zone.local(Time.now.year,12,1,16,30)
end
it "should set end time to the given date" do
#meeting.date = Date.new(Time.now.year,12,1)
#meeting.end_time_string = '6pm'
#meeting.send(:parse_time)
#meeting.end_time.must_equal Time.zone.local(Time.now.year,12,1,18,0)
end
end
describe "displaying time" do
before do
#meeting.date = Date.new(Date.today.year,12,1)
#meeting.start_time = Time.new(Date.today.year,12,1,16,30)
#meeting.end_time = Time.new(Date.today.year,12,1,18,0)
end
it "should print a friendly time" do
#meeting.time.must_equal "December 1; 4:30 - 6:00 PM"
end
end
describe "displaying if nil" do
it "should handle nil date" do
#meeting.date_string.must_equal ""
end
it "should handle nil start_time" do
#meeting.start_time_string.must_equal ""
end
it "should handle nil end_time" do
#meeting.end_time_string.must_equal ""
end
end
describe "time zones" do
before do
#meeting.assign_attributes(
time_zone: 'Central Time (US & Canada)',
date_string: "December 1, #{Time.now.year}",
start_time_string: "4:30 PM",
end_time_string: "6:00 PM"
)
#meeting.save
end
it "should set meeting start times in the given time zone" do
Time.zone = 'Central Time (US & Canada)'
#meeting.start_time.must_equal Time.zone.local(Time.now.year,12,1,16,30)
end
it "should set the correct UTC offset" do
#meeting.start_time.utc_offset.must_equal -(6*60*60)
end
after do
#meeting.destroy
end
end
describe "updating" do
before do
#m = Meeting.create(
time_zone: 'Central Time (US & Canada)',
date_string: "December 1, #{Time.now.year}",
start_time_string: "4:30 PM",
end_time_string: "6:00 PM"
)
#m.update_attributes start_time_string: '2pm', end_time_string: '3pm'
Time.zone = 'Central Time (US & Canada)'
end
it "should update start time via mass assignment" do
#m.start_time.must_equal Time.zone.local(Time.now.year,12,1,14,00)
end
it "should update end time via mass assignment" do
#m.end_time.must_equal Time.zone.local(Time.now.year,12,1,15,00)
end
after do
#m.destroy
end
end
end
I have even specifically mixed in creating and updating records via mass assignment in later test methods to ensure that those work as expected. All those tests pass.
I appreciate any insight into the following:
Why doesn't the date update in the controller#update action?
Why aren't times getting the year from the date that is set? This works in the model and in specs, but not when submitted via form through the controller.
Why don't times get set to the time zone that is passed in from the form? Again, these specs pass, what is wrong on the controller?
Why won't times display in their time zone on the front end?
Thanks for the help, I feel like I must be losing the forest for the trees on this one as I've been going at it for hours.
Update:
Thanks to the help of AJcodez, I saw some of the issues:
Was assigning date wrong, thanks AJ! Now using:
if #date_string.present?
self.date = Chronic.parse(#date_string).to_date
elsif self.date.nil?
self.date = Date.today
end
I was using Chronic correctly, my mistake was at the database layer! I set the fields in the database to time instead of datetime, which ruins everything. Lesson to anyone reading this: never ever use time as a database field (unless you understand exactly what it does and why you're using it instead of datetime).
Same problem as above, changing the fields to datetime fixed the problem.
The problem here has to do with accessing time in the model vs. the view. If I move these time formatting methods into a helper so they're called in the current request scope they will work correctly.
Thanks AJ! Your suggestions got me past my blind spot.
Well here goes..
1 . Why doesn't the date update in the controller#update action?
I see two potential issues. Looks like you're not parsing the dates again. Try this:
def update
#meeting = #member.meetings.find(params[:id])
#meeting.assign_attributes params[:meeting]
#meeting.send :parse_time
if #meeting.save
...
assign_attributes sets but doesnt save new values: http://apidock.com/rails/ActiveRecord/AttributeAssignment/assign_attributes
Also, in your parse_time method, you use this assignment: self.date ||= which will always set self.date back to itself if it is assigned. In other words you can't update the date unless its falsey.
2 . Why aren't times getting the year from the date that is set? This works in the model and in specs, but not when submitted via form through the controller.
No idea, looks like you are using Chronic#parse correctly.
3 . Why don't times get set to the time zone that is passed in from the form? Again, these specs pass, what is wrong on the controller?
Try debugging time_zone and make sure it is returning whats in params[:meeting][:time_zone]. Again it looks correct by Chronic.
Side note: if you pass an invalid string to Time#zone= it will blow up with an error. For instance Time.zone = 'utc' is all bad.
4 . Why won't times display in their time zone on the front end?
See Time#in_time_zone http://api.rubyonrails.org/classes/Time.html#method-i-in_time_zone and just explicitly name your time zone every time.
Not sure if you're already doing this, but try to explicitly save Times in UTC on the database, and then display them in local time.
Related
I have to create a list of 24 months with the same day amongst them, properly handling the months that do not have day 29, 30 or 31.
What I currently do is:
def dates_list(first_month, assigned_day)
(0...24).map do |period|
begin
(first_month + period.months).change(day: assigned_day)
rescue ArgumentError
(first_month + period.months).end_of_month
end
end
end
I need to rescue from ArgumentError as some cases raise it:
Date.parse('10-Feb-2019').change(day: 30)
# => ArgumentError: invalid date
I am looking for a safe and elegant solution that might already exist in ruby or rails. Something like:
Date.parse('10-Feb-2019').safe_change(day: 30) # => 28-Feb-2019
So I can write:
def dates_list(first_month, assigned_day)
(0...24).map do |period|
(first_month + period.months).safe_change(day: assigned_day)
end
end
Does that exist or I would need to monkey patch Date?
Workarounds (like a method that already creates this list) are very welcome.
UPDATE
The discussion about what to do with negative and 0 days made me realize this function is trying to guess the user's intent. And it also hard codes how many months to generate, and to generate by month.
This got me thinking what is this method doing? It's generates a list of advancing months, of a fixed size, and modifying them in a fixed way, and guessing what the user wants. If your function description includes "and" you probably need multiple functions. We separate generating the list of dates from modifying the list. We replace the hard coded parts with parameters. And instead of guessing what the user wants, we let them tell us with a block.
def date_generator(from, by:, how_many:)
(0...how_many).map do |period|
date = from + period.send(by)
yield date
end
end
The user can be very explicit about what they want to change. No surprises for the user nor the reader.
p date_generator(Date.parse('2019-02-01'), by: :month, how_many: 24) { |month|
month.change(day: month.end_of_month.day)
}
We can take this a step further by turning it into an Enumerator. Then you can have as many as you like and do whatever you like with them using normal Enumerable methods..
INFINITY = 1.0/0.0
def date_iterator(from, by:)
Enumerator.new do |block|
(0..INFINITY).each do |period|
date = from + period.send(by)
block << date
end
end
end
p date_iterator(Date.parse('2019-02-01'), by: :month)
.take(24).map { |date|
date.change(day: date.end_of_month.day)
}
Now you can generate any list of dates, iterating by any field, of any length, with any changes. Rather than being hidden in a method, what's happening is very explicit to the reader. And if you have a special, common case you an wrap this in a method.
And the final step would be to make it a Date method.
class Date
INFINITY = 1.0/0.0
def iterator(by:)
Enumerator.new do |block|
(0..INFINITY).each do |period|
date = self + period.send(by)
block << date
end
end
end
end
Date.parse('2019-02-01')
.iterator(by: :month)
.take(24).map { |date|
date.change(day: date.end_of_month.day)
}
And if you have a special, common case, you can write a special case function for it, give it a descriptive name, and document its special behaviors.
def next_two_years_of_months(date, day:)
if day <= 0
raise ArgumentError, "The day must be positive"
end
date.iterator(by: :month)
.take(24)
.map { |next_date|
next_date.change(day: [day, next_date.end_of_month.day].min)
}
end
PREVIOUS ANSWER
My first refactoring would be to remove the redundant code.
require 'date'
def dates_list(first_month, assigned_day)
(0...24).map do |period|
next_month = first_month + period.months
begin
next_month.change(day: assigned_day)
rescue ArgumentError
next_month.end_of_month
end
end
end
At this point, imo, the function is fine. It's clear what's happening. But you can take it a step further.
def dates_list(first_month, assigned_day)
(0...24).map do |period|
next_month = first_month + period.months
day = [assigned_day, next_month.end_of_month.day].min
next_month.change(day: day)
end
end
I think that's marginally better. It makes the decision a little more explicit and doesn't paper over other possible argument errors.
If you find yourself doing this a lot, you could add it as a Date method.
class Date
def change_day(day)
change(day: [day, end_of_month.day].min)
end
end
I'm not so hot on either change_day nor safe_change. Neither really says "this will use the day or if it's out of bounds the last day of the month" and I'm not sure how to express that.
I have left my application.rb as it was and by default it is storing datetime in UTC.
Now at the UI level (for displaying only) I want to convert the datetime to the user specific timezone.
How can I convert the UTC datetime I get from postgresql and then convert it to the timezone that I will store for each user.
I want to make this conversion using the Offset so like: -5:00 or +4:00
Is it ok to do this, because I was just checking some locations and it seems their offset changes during the season.
E.g. virginia goes from UTC -5 to UTC -4 depending on the month.
http://www.timeanddate.com/time/zone/usa/richmond
If UTC is not consistant then I should just store the zone in the database for each user?
When I made an app with scheduling events, I had to add timezones to the User model to offset their scheduling.
When I recorded the offset, I used the following code:
<%= time_zone_select "user",
"timezone",
ActiveSupport::TimeZone.us_zones,
{default: "Mountain Time (US & Canada)"},
{class: "form-control"} %>
Because Rails has time zones built into their ActiveSupport model. You could use ActiveSupport::TimeZone.all if you wanted users to be global.
Here's some info on time_zone_select
Then depending on how you use it, you can just set
Time.zone = current_user.timezone unless current_user.nil?
or something similar in your application.rb file.
UPDATE
I personally used it to set the timezone in the controller on the only 2 actions where it was necessary.
def index
#user = current_user
#pending = #user.share_events.where("time > ?", DateTime.now.in_time_zone(#user.timezone))
#complete = #user.share_events.where("time <= ?", DateTime.now.in_time_zone(#user.timezone))
end
def new
#user = current_user
Time.zone = #user.timezone
#post = Post.find(params[:post_id])
#share_event = ShareEvent.new
end
Method trying to test:
def self.by_date(date)
where("DATE(created_at) = ?", date)
end
Comments.yml (fixture):
one:
user_id: 1
job_id: 24
content: "This is a test"
Current Test:
require 'test_helper'
require 'date'
class CommentTest < ActiveSupport::TestCase
setup do
#comment = comments(:one)
end
test 'organizes by date' do
#comment.created_at = Date.today
assert_equal #comment.created_at, Comment.by_date(Date.today).first.created_at
end
end
I end up with:
2) Failure:
CommentTest#test_organizes_by_date
--- expected
+++ actual
## -1 +1 ##
-Fri, 22 Apr 2016 00:00:00 UTC +00:00
+Fri, 22 Apr 2016 20:48:42 UTC +00:00
I am assuming there is a way more efficent way to test this but have found no luck. Any ideas?
#comment.created_at is a Date, but Comment.by_date(Date.today).first.created_at is a DateTime object.
Try to convert your DateTime object to Date:
assert_equal #comment.created_at, Comment.by_date(Date.today).first.created_at.to_date
I think you want to test that the correct comments are returned by the self.by_date method. Does the precise time matter or can it just be within the same day or same hour?
Create another comment and set its created date to yesterday. Then test that the result includes the comment created today and not the comment created yesterday.
class CommentTest < ActiveSupport::TestCase
setup do
#comment1 = comments(:one)
#comment2 = comments(:one)
end
test 'organizes by date' do
#comment1.created_at = Time.now
#comment2.created_at = Time.now - 1.day
assert_equal [#comment1], Comment.by_date(Time.now)
assert_equal [#comment2], Comment.by_date(Time.now - 1.day)
end
end
You'll need to do some additional date manipulation in the method to get comments for the day as opposed to the precise time.
def self.by_date
where(created_at: Time.now.day)
end
If you want the precise time of creation, maybe look at using TimeCop which is helpful for testing on precise timings.
Apologies in advance for minitest syntax errors, I generally use rspec.
I have a model Foo with two datetime attributes called start_time and end_time. Foo has a validation logic for each attribute:
Model:
...
validate :dates_logic_validation, on: :save
...
def dates_logic_validation
if start_time < Time.now
errors.add(:start_time, 'Start time must be greater or equal to today\'\s date')
return false
elsif start_time > end_time
errors.add(:end_time, 'End time must be greater than start time')
return false
else
true
end
end
The validation error handling works fine when I try to create / update Foo via the console. Using datetimepicker gem, I am getting both dates in params hash. In my foos#create, I want to be able to parse this daterange param and assign it foo.start_time and foo.end_time. To do so, I added an instance method:
def parse_date_range(daterange) #<= param is passed as "12/02/2015 - 01/14/2016"
start_time = daterange.split("-")[0].strip!
end_time = daterange.split("-")[1].strip!
update_attributes(start_time: start_time, end_time: end_time)
end
then calling it like so:
Controller (#create):
def create
#foo= Foo.new(foo_params)
#foo.parse_date_range(params[:daterange])
if #foo.valid?
#foo.save
redirect_to foos_path(#foo)
else
render :new
end
end
and here is where the music stops. If I log #{start_time} and #{end_time} after I call parse_data_range on #foo, both will be empty. Which suggests that I probably can't call update_attributes on a non-existing object. If that's the case, what would be the best solution?
I think it's a mistake to do a second database query (update_attributes) when instead you can extract the dates from params in the controller like this:
class FoosController < ApplicationController
before_action :extract_start_end_times, only: :create
def create
#foo = Foo.new(foo_params)
if #foo.valid?
#foo.save
redirect_to foos_path(#foo)
else
render :new
end
end
# ...
private
def extract_start_end_times
return unless params[:daterange].present?
daterange = params.delete(:daterange)
start_time, end_time = daterange.split(/\s*-\s*/)
.map {|date| Date.strptime(date, '%m/%d/%Y') }
params.merge!(start_time: start_time, end_time: end_time)
end
end
Note the use of Date.strptime(date, '%m/%d/%Y'). This is necessary because your dates are in an ambiguous format (Ruby has no way to know if "12/02/2015" is Dec. 2 or Feb. 12).
If you're using strong parameters you may have to modify your foo_params method to accommodate the start_time and end_time params.
This should get to work.
update_attributes(start_time: Date.parse(start_time), end_time: Date.parse(end_time))
You can also use
DateTime.parse("12/02/2015")
=> Thu, 12 Feb 2015 00:00:00 +0000
My rails application is covered by many tests. All the tests pass in regular situations. That is, excluding when it's late at night.
There's actually a few tests that end up failing when it's night. All these tests involve modifying a model's time attribute and seeing if related models are affected.
test "changing time should affect hours" do
// ..User is loaded.
user.attend(event)
assert_equal 1, user.hours // User attends a 1 hour event that has passed.
// Move event to the future.
event.update_attributes(date: Date.today,
start_time: Time.now,
end_time: Time.now + 1.hour)
assert_equal 0, attendance_of(user).hours // Passes in day, fails during night
end
test "valid event creation" do
// Count does NOT change by 1 at night.
assert_difference '#group.events.count', 1 do
post group_events_path(#group), event: { ...
date: Date.today,
start_time: Time.now,
end_time: Time.now + 1.hour,
... }
end
end
What is going on here? For reference, here's what I currently use to determine when to update an attendance (which is something that an event has). This comes from the event controller:
def not_ended?
date.future? || (date.today? &&
(Time.now.seconds_since_midnight < end_time.seconds_since_midnight))
end
def update_attendances
// ... Determine the new date, start, and end time values through ActiveRecord::Dirty
if not_ended?
remove_checks = true
end
attendances.each do |attendance|
new_checked = remove_checks ? false : attendance.checked
attendance.update_attributes(went: new_start, left: new_end,
checked: new_checked)
end
end
end
Validating an event to make sure its times aren't weird:
def valid_time
if start_time == end_time
// Error...
end
if start_time > end_time
// Error...
end
end
Time zone in application.rb:
config.time_zone = 'Pacific Time (US & Canada)'
Use Time.zone.now or timecop.freeze to freeze the timezone as your test is probably using system time and not rails app config time
Your not_ended? method is broken. It doesn't work when the event starts before midnight, but ends after. In that case the date is today (assuming the data is based on start time) but the number of seconds since midnight of the end of the event is less that the current time.
In these situations you shouldn't be trying to deal with dates and times separately. You should have a way to retrieve the datetime of the end of the event and compare that with the current datetime.