Files
sure/app/models/period.rb
Roger Saner b3c88e09f3 Feature: remember value of chart period selector (#1528)
* feat: remember chart period by last selection not user preferences

* feat: schema update

* fix: revert unnecessary parts of schema.rb update

* fix: check period key is valid before setting it

* revert: no database changes and keep the UI setting

* refactor: don't store the default period in the session, just use the user

* fix: migration

The migration uses the User model directly, which loads all current enums
including ui_layout which doesn't exist yet at that point in migration history.
Fix it with raw SQL.

* revert: not relevant to this PR
2026-04-21 19:02:41 +02:00

221 lines
5.8 KiB
Ruby

class Period
include ActiveModel::Validations, Comparable
class InvalidKeyError < StandardError; end
attr_reader :key, :start_date, :end_date
validates :start_date, :end_date, presence: true, if: -> { PERIODS[key].nil? }
validates :key, presence: true, if: -> { start_date.nil? || end_date.nil? }
validate :must_be_valid_date_range
PERIODS = {
"last_day" => {
date_range: -> { [ 1.day.ago.to_date, Date.current ] },
label_short: "1D",
label: "Last Day",
comparison_label: "vs. yesterday"
},
"current_week" => {
date_range: -> { [ Date.current.beginning_of_week, Date.current ] },
label_short: "WTD",
label: "Current Week",
comparison_label: "vs. start of week"
},
"last_7_days" => {
date_range: -> { [ 7.days.ago.to_date, Date.current ] },
label_short: "7D",
label: "Last 7 Days",
comparison_label: "vs. last week"
},
"current_month" => {
date_range: -> { [ Date.current.beginning_of_month, Date.current ] },
label_short: "MTD",
label: "Current Month",
comparison_label: "vs. start of month"
},
"last_month" => {
date_range: -> { [ 1.month.ago.beginning_of_month.to_date, 1.month.ago.end_of_month.to_date ] },
label_short: "LM",
label: "Last Month",
comparison_label: "vs. last month"
},
"last_30_days" => {
date_range: -> { [ 30.days.ago.to_date, Date.current ] },
label_short: "30D",
label: "Last 30 Days",
comparison_label: "vs. last 30 days"
},
"last_90_days" => {
date_range: -> { [ 90.days.ago.to_date, Date.current ] },
label_short: "90D",
label: "Last 90 Days",
comparison_label: "vs. last quarter"
},
"current_year" => {
date_range: -> { [ Date.current.beginning_of_year, Date.current ] },
label_short: "YTD",
label: "Current Year",
comparison_label: "vs. start of year"
},
"last_365_days" => {
date_range: -> { [ 365.days.ago.to_date, Date.current ] },
label_short: "365D",
label: "Last 365 Days",
comparison_label: "vs. 1 year ago"
},
"last_5_years" => {
date_range: -> { [ 5.years.ago.to_date, Date.current ] },
label_short: "5Y",
label: "Last 5 Years",
comparison_label: "vs. 5 years ago"
},
"last_10_years" => {
date_range: -> { [ 10.years.ago.to_date, Date.current ] },
label_short: "10Y",
label: "Last 10 Years",
comparison_label: "vs. 10 years ago"
},
"all_time" => {
date_range: -> {
oldest_date = Current.family&.oldest_entry_date
# If no family or no entries exist, use a reasonable historical fallback
# to ensure "All Time" represents a meaningful range, not just today
start_date = if oldest_date && oldest_date < Date.current
oldest_date
else
5.years.ago.to_date
end
[ start_date, Date.current ]
},
label_short: "All",
label: "All Time",
comparison_label: "vs. beginning"
}
}
class << self
def valid_key?(key)
PERIODS.key?(key)
end
def from_key(key)
unless PERIODS.key?(key)
raise InvalidKeyError, "Invalid period key: #{key}"
end
start_date, end_date = PERIODS[key].fetch(:date_range).call
new(key: key, start_date: start_date, end_date: end_date)
end
def custom(start_date:, end_date:)
new(start_date: start_date, end_date: end_date)
end
def all
PERIODS.map { |key, period| from_key(key) }
end
def as_options
all.map { |period| [ period.label_short, period.key ] }
end
def current_month_for(family)
return from_key("current_month") unless family&.uses_custom_month_start?
family.current_custom_month_period
end
def last_month_for(family)
return from_key("last_month") unless family&.uses_custom_month_start?
current_start = family.custom_month_start_for(Date.current)
last_month_date = current_start - 1.day
start_date = family.custom_month_start_for(last_month_date)
end_date = family.custom_month_end_for(last_month_date)
custom(start_date: start_date, end_date: end_date)
end
end
PERIODS.each do |key, period|
define_singleton_method(key) do
from_key(key)
end
end
def initialize(start_date: nil, end_date: nil, key: nil, date_format: "%b %d, %Y")
@key = key
@start_date = start_date
@end_date = end_date
@date_format = date_format
validate!
end
def <=>(other)
[ start_date, end_date ] <=> [ other.start_date, other.end_date ]
end
def date_range
start_date..end_date
end
def days
(end_date - start_date).to_i + 1
end
def within?(other)
start_date >= other.start_date && end_date <= other.end_date
end
def interval
if days > 1825 # 5 years
"1 month"
elsif days > 366
"1 week"
else
"1 day"
end
end
def label
if key_metadata
key_metadata.fetch(:label)
else
"Custom Period"
end
end
def label_short
if key_metadata
key_metadata.fetch(:label_short)
else
"Custom"
end
end
def comparison_label
if key_metadata
key_metadata.fetch(:comparison_label)
else
"#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}"
end
end
private
def key_metadata
@key_metadata ||= PERIODS[key]
end
def must_be_valid_date_range
return if start_date.nil? || end_date.nil?
unless start_date.is_a?(Date) && end_date.is_a?(Date)
errors.add(:start_date, "must be a valid date, got #{start_date.inspect}")
errors.add(:end_date, "must be a valid date, got #{end_date.inspect}")
return
end
errors.add(:start_date, "must be before end date") if start_date > end_date
end
end