railsui_charts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ module ChartHelper
5
+ # Forms whose placeholder should be a disc rather than a plotting area.
6
+ CIRCULAR_TYPES = %i[pie donut polar_area radar].freeze
7
+
8
+ def railsui_chart(data, type: :line, **options)
9
+ # An empty dataset is a normal day one, not an error. Rendering axes
10
+ # around nothing looks like a chart that failed rather than a chart with
11
+ # nothing to show yet.
12
+ return railsui_chart_empty(**empty_options(options)) if ApexOptionsBuilder.blank?(data)
13
+
14
+ config = ApexOptionsBuilder.new(data, type: type, **options).build
15
+ id = options[:id] || "rui-chart-#{SecureRandom.hex(4)}"
16
+
17
+ table = options[:accessible] != false ? accessibility_table(config, id: id) : nil
18
+
19
+ content_tag(:div,
20
+ id: id,
21
+ class: "railsui-chart",
22
+ # Charts draw when they scroll into view, so the space has to
23
+ # be held from the start. Without it the page is short on
24
+ # load and grows under the reader as they scroll.
25
+ style: reserved_height(config),
26
+ data: { controller: "railsui-chart", "railsui-chart-options-value": config.to_json }) do
27
+ table
28
+ end
29
+ end
30
+
31
+ # One small chart per series, sharing a y-scale.
32
+ #
33
+ # This is the honest answer when there are more categories than a single
34
+ # chart can hold. Eight lines on one axis is a plate of spaghetti, and a
35
+ # ninth colour is not distinguishable from the others anyway — facets scale
36
+ # where colour does not.
37
+ #
38
+ # <%= railsui_small_multiples @plans, type: :area, columns: 3 %>
39
+ def railsui_small_multiples(series, type: :line, height: 120, columns: 3, **options)
40
+ return railsui_chart_empty(**empty_options(options)) if ApexOptionsBuilder.blank?(series)
41
+
42
+ entries = Array(series)
43
+ shared = shared_scale(entries)
44
+
45
+ content_tag(:div, class: "railsui-small-multiples", style: "--rui-small-multiple-columns: #{columns.to_i}") do
46
+ safe_join(entries.each_with_index.map do |entry, index|
47
+ content_tag(:div, class: "railsui-small-multiple") do
48
+ safe_join([
49
+ content_tag(:p, entry[:name] || entry["name"] || "Series #{index + 1}", class: "railsui-small-multiple__title"),
50
+ # Every facet takes the same colour. The title carries identity
51
+ # here, so spending a hue on it would say nothing extra.
52
+ railsui_chart(entry[:data] || entry["data"], type: type, height: height,
53
+ yaxis: shared.merge(tickAmount: 3), **options)
54
+ ])
55
+ end
56
+ end)
57
+ end
58
+ end
59
+
60
+ # Facets only compare if they share a scale. Left to themselves each one
61
+ # would fit its own data and a small series would look like a large one.
62
+ def shared_scale(entries)
63
+ values = entries.flat_map { |entry| Array(entry[:data] || entry["data"]).map { |point| ApexOptionsBuilder.value_of(point) } }.compact
64
+ return {} if values.empty?
65
+
66
+ min, max = values.minmax
67
+ return {} if min == max
68
+
69
+ { min: min, max: max }
70
+ end
71
+
72
+ # Holds the chart's footprint so a card keeps its shape whether or not the
73
+ # query came back with anything.
74
+ def railsui_chart_empty(title: nil, description: nil, height: nil)
75
+ chart_state(:empty, title: title || "No data", description: description, height: height)
76
+ end
77
+
78
+ def railsui_chart_error(title: nil, description: nil, height: nil)
79
+ chart_state(:error, title: title || "Couldn't load this chart", description: description, height: height)
80
+ end
81
+
82
+ # Server-rendered placeholder for a chart whose data has not arrived yet —
83
+ # the thing a Turbo frame shows before it swaps in the real one. Pass the
84
+ # `type:` it will become so the placeholder is the shape being waited on.
85
+ def railsui_chart_skeleton(height: nil, type: :line, label: "Loading chart")
86
+ classes = ["railsui-chart-skeleton"]
87
+ classes << "railsui-chart-skeleton--circular" if CIRCULAR_TYPES.include?(type.to_sym)
88
+
89
+ content_tag(:div,
90
+ class: "railsui-chart-state railsui-chart-state--loading",
91
+ style: state_height(height),
92
+ role: "status",
93
+ aria: { label: label, busy: true }) do
94
+ content_tag(:span, "", class: classes.join(" "), aria: { hidden: true })
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def chart_state(kind, title:, description:, height:)
101
+ content_tag(:div,
102
+ class: "railsui-chart-state railsui-chart-state--#{kind}",
103
+ style: state_height(height),
104
+ role: "status") do
105
+ content_tag(:div, class: "railsui-chart-state__body") do
106
+ safe_join([
107
+ content_tag(:p, title, class: "railsui-chart-state__title"),
108
+ description.present? ? content_tag(:p, description, class: "railsui-chart-state__description") : nil
109
+ ].compact)
110
+ end
111
+ end
112
+ end
113
+
114
+ def reserved_height(config)
115
+ height = config.dig(:chart, :height)
116
+ return if height.blank?
117
+
118
+ "min-height: #{height.to_i}px"
119
+ end
120
+
121
+ def state_height(height)
122
+ height ||= RailsuiCharts.config.default_height
123
+ "min-height: #{height.to_i}px"
124
+ end
125
+
126
+ # `empty:` takes a string for the headline, or a hash to say more.
127
+ def empty_options(options)
128
+ given = options[:empty]
129
+ base = { height: options[:height] }
130
+
131
+ case given
132
+ when String then base.merge(title: given)
133
+ when Hash then base.merge(given.symbolize_keys)
134
+ else base
135
+ end
136
+ end
137
+
138
+ # Every chart ships a visually hidden table so no value is reachable only by
139
+ # hovering a mark.
140
+ def accessibility_table(config, id:)
141
+ series = config[:series]
142
+ return if series.blank?
143
+
144
+ if series.is_a?(Array) && series.first.is_a?(Numeric)
145
+ # Pie / donut / polar area: a flat array of values with separate labels.
146
+ columns = [{ name: "Value", data: series }]
147
+ categories = config[:labels] || []
148
+ else
149
+ columns = Array(series).map { |s| { name: s[:name] || "Value", data: s[:data] } }
150
+ categories = config.dig(:xaxis, :categories) || []
151
+ end
152
+
153
+ rows = columns.first&.dig(:data)
154
+ return if rows.blank?
155
+
156
+ content_tag(:table, class: "sr-only", aria: { label: "Chart data" }) do
157
+ safe_join([
158
+ content_tag(:caption, "Data for chart #{id}"),
159
+ content_tag(:thead) do
160
+ content_tag(:tr) do
161
+ safe_join(
162
+ [content_tag(:th, "Category")] +
163
+ columns.map { |column| content_tag(:th, column[:name]) }
164
+ )
165
+ end
166
+ end,
167
+ content_tag(:tbody) do
168
+ safe_join(
169
+ rows.each_with_index.map do |_, index|
170
+ content_tag(:tr) do
171
+ safe_join(
172
+ [content_tag(:td, categories[index] || index + 1)] +
173
+ columns.map { |column| content_tag(:td, cell_value(column[:data][index])) }
174
+ )
175
+ end
176
+ end
177
+ )
178
+ end
179
+ ])
180
+ end
181
+ end
182
+
183
+ def cell_value(value)
184
+ case value
185
+ when Array then value.join(", ")
186
+ when Hash
187
+ # A bubble carries three numbers that all mean something here. Anything
188
+ # else keeps its label in the category column already, so repeating it
189
+ # beside the value just reads as "A, 6" to a screen reader.
190
+ value.key?(:z) ? value.values_at(:x, :y, :z).compact.join(", ") : value[:y]
191
+ else value
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ class Configuration
5
+ attr_accessor :default_height, :default_currency, :colors, :series_colors, :theme_css_prefix, :extra_types
6
+ attr_accessor :labelled_point_types, :typography, :geometry
7
+
8
+ # Categorical hues carry identity, so the order is the colourblind-safety
9
+ # mechanism rather than a style choice — it was picked by validating every
10
+ # ordering of these hues and keeping one that clears the lightness band,
11
+ # chroma floor, CVD separation, normal-vision floor, and 3:1 contrast in
12
+ # both light and dark. Reorder it and those guarantees go with it.
13
+ #
14
+ # Forms where any two marks can sit side by side (pie, donut, polar area,
15
+ # scatter, bubble) hold to a stricter all-pairs test, which these hues clear
16
+ # for the first four slots. Past four, fold the tail into "Other".
17
+ SERIES_COUNT = 8
18
+
19
+ SERIES_FALLBACKS = %w[
20
+ #6366f1 #ea580c #db2777 #c026d3
21
+ #65a30d #0284c7 #dc2626 #0d9488
22
+ ].freeze
23
+
24
+ def initialize
25
+ @default_height = 250
26
+ @default_currency = "$"
27
+ # Chart types registered by an extension, such as Rails UI Charts Pro.
28
+ @extra_types = []
29
+ @labelled_point_types = []
30
+ @theme_css_prefix = "--rui-chart"
31
+ @series_colors = (1..SERIES_COUNT).map { |i| "var(--rui-chart-series-#{i}, #{SERIES_FALLBACKS[i - 1]})" }
32
+ @colors = {
33
+ primary: "var(--rui-chart-primary, #4f46e5)",
34
+ secondary: "var(--rui-chart-secondary, #0ea5e9)",
35
+ accent: "var(--rui-chart-accent, #10b981)",
36
+ muted: "var(--rui-chart-muted, #94a3b8)",
37
+ grid: "var(--rui-chart-grid, rgba(148, 163, 184, 0.2))",
38
+ text: "var(--rui-chart-text, #64748b)",
39
+ # The card colour behind a chart. Touching marks separate with a gap in
40
+ # this colour rather than a stroke drawn around them.
41
+ surface: "var(--rui-chart-surface, #ffffff)"
42
+ }
43
+
44
+ # Type rides the same channel the colours do, because Apex takes these as
45
+ # CSS strings and the controller resolves `var()` against the chart's own
46
+ # computed style before handing the options over. That is worth more than
47
+ # a Ruby setting here: a dense card can shrink the type on its own charts
48
+ # without a second configuration, and a media query can do it per theme.
49
+ #
50
+ # The defaults are the sizes that were hardcoded before, so nothing moves
51
+ # until someone sets a variable.
52
+ @typography = {
53
+ family: "var(--rui-chart-font-family, inherit)",
54
+ size: "var(--rui-chart-font-size, 12px)",
55
+ # Legend, annotation labels, and the compact breakpoint under 640px.
56
+ size_sm: "var(--rui-chart-font-size-sm, 11px)"
57
+ }
58
+
59
+ # Numbers, and that is the whole reason they are here rather than in the
60
+ # CSS. A resolved CSS variable arrives as a string, and Apex does
61
+ # arithmetic on these — "4" + 1 is "41", so a radius set that way would
62
+ # silently produce a chart with corners nobody asked for.
63
+ @geometry = {
64
+ bar_radius: 4,
65
+ stroke_width: 2,
66
+ marker_size: 0,
67
+ marker_hover_size: 6
68
+ }
69
+ end
70
+ # Register a chart type this gem does not ship, so it passes validation and
71
+ # is drawn through the same options builder as everything else rather than
72
+ # around it.
73
+ #
74
+ # RailsuiCharts.config.register_type :treemap, points: :labelled
75
+ #
76
+ # `points: :labelled` keeps each point as {x:, y:}. Most types put the label
77
+ # on the axis and send bare values, but a treemap draws its labels inside
78
+ # the rectangles, so they have to stay in the data.
79
+ def register_type(type, points: :values)
80
+ type = type.to_sym
81
+
82
+ @extra_types |= [type]
83
+ @labelled_point_types |= [type] if points == :labelled
84
+
85
+ type
86
+ end
87
+
88
+ end
89
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace RailsuiCharts
6
+
7
+ initializer "railsui_charts.helpers" do
8
+ ActiveSupport.on_load :action_controller do
9
+ helper RailsuiCharts::ChartHelper
10
+ helper RailsuiCharts::MetricHelper
11
+ helper RailsuiCharts::FilterHelper
12
+ end
13
+ end
14
+
15
+ initializer "railsui_charts.assets" do |app|
16
+ app.config.assets.precompile << "railsui_charts.css"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ module FilterHelper
5
+ # Renders the single filter row that scopes every chart below it.
6
+ #
7
+ # <%= railsui_chart_filters @filters, url: dashboard_path %>
8
+ #
9
+ # Submits as a plain GET form, so it works without JavaScript and each
10
+ # slice gets a shareable URL. With Turbo, the controller submits on change
11
+ # and only the frame re-renders.
12
+ #
13
+ # The controls are deliberately bare — text and a chevron, with the pill
14
+ # only appearing on hover. Filter chrome sits above the data all day, so it
15
+ # earns its weight only while someone is reaching for it.
16
+ #
17
+ # An app that would rather use its own form styles passes them in. Doing so
18
+ # drops the built-in chevron and switch, since those belong to this
19
+ # treatment:
20
+ #
21
+ # <%= railsui_chart_filters @filters, url: dashboard_path,
22
+ # select_class: "form-select w-auto", checkbox_class: "form-input-checkbox" %>
23
+ def railsui_chart_filters(filters, url: nil, frame: nil, select_class: nil, checkbox_class: nil)
24
+ bare_selects = select_class.nil?
25
+ switch = checkbox_class.nil?
26
+ select_class ||= "railsui-chart-filters__select"
27
+ checkbox_class ||= "railsui-chart-filters__checkbox"
28
+
29
+ form_tag(url, method: :get, class: "railsui-chart-filters", data: filter_form_data(frame)) do
30
+ safe_join([
31
+ filter_field("Date range",
32
+ select_tag(:range, options_for_select(filters.presets.map { |preset| [preset.label, preset.key] }, filters.preset.key), **filter_select_options(select_class)),
33
+ chevron: bare_selects),
34
+ filter_field("Interval",
35
+ select_tag(:interval, options_for_select(filters.intervals, filters.interval.to_s), **filter_select_options(select_class)),
36
+ chevron: bare_selects),
37
+ filter_compare(filters, checkbox_class, switch: switch),
38
+ # Without Turbo or Stimulus the selects still need a way to apply.
39
+ content_tag(:noscript, submit_tag("Apply", class: "railsui-chart-filters__apply", data: { disable_with: nil }))
40
+ ])
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def filter_form_data(frame)
47
+ data = { controller: "railsui-chart-filters" }
48
+ data[:turbo_frame] = frame if frame.present?
49
+ data
50
+ end
51
+
52
+ def filter_field(label, control, chevron:)
53
+ content_tag(:div, class: "railsui-chart-filters__field") do
54
+ safe_join([
55
+ content_tag(:span, label, class: "railsui-chart-filters__label"),
56
+ # The chevron rides a wrapper rather than a background image on the
57
+ # select, so it inherits currentColor and follows the theme.
58
+ chevron ? content_tag(:span, control, class: "railsui-chart-filters__control") : control
59
+ ])
60
+ end
61
+ end
62
+
63
+ def filter_select_options(select_class)
64
+ {
65
+ class: select_class,
66
+ data: { action: "change->railsui-chart-filters#submit" }
67
+ }
68
+ end
69
+
70
+ def filter_compare(filters, checkbox_class, switch:)
71
+ classes = ["railsui-chart-filters__compare"]
72
+ classes << "railsui-chart-filters__compare--switch" if switch
73
+
74
+ content_tag(:label, class: classes.join(" ")) do
75
+ safe_join([
76
+ # Paired hidden field so unchecking sends an explicit value rather
77
+ # than dropping the param and falling back to the default.
78
+ hidden_field_tag(:compare, "0", id: nil),
79
+ check_box_tag(:compare, "1", filters.compare?,
80
+ class: switch ? "railsui-chart-filters__switch" : checkbox_class,
81
+ data: { action: "change->railsui-chart-filters#submit" }),
82
+ # Adjacent sibling of the input, which is what drives the checked and
83
+ # focus states.
84
+ switch ? content_tag(:span, "", class: "railsui-chart-filters__track", aria: { hidden: true }) : nil,
85
+ content_tag(:span, "Compare to previous period")
86
+ ].compact)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ # Resolves request params into the window every chart on a page shares.
5
+ #
6
+ # Filters belong above the charts, not inside them: one row scopes the whole
7
+ # view so every chart re-renders against the same slice. A per-card date
8
+ # picker invites two cards to disagree about what "this week" means.
9
+ #
10
+ # filters = RailsuiCharts::Filters.new(params)
11
+ # filters.range # => Mon, 06 Aug..Mon, 12 Aug
12
+ # filters.interval # => :day
13
+ # filters.previous_range # => the preceding 7 days, when comparing
14
+ class Filters
15
+ Preset = Struct.new(:key, :label, :length, :interval, :unit, keyword_init: true)
16
+
17
+ PRESETS = [
18
+ Preset.new(key: "24h", label: "Last 24 hours", length: 24, interval: :hour, unit: :hour),
19
+ Preset.new(key: "7d", label: "Last 7 days", length: 7, interval: :day, unit: :day),
20
+ Preset.new(key: "30d", label: "Last 30 days", length: 30, interval: :day, unit: :day),
21
+ Preset.new(key: "90d", label: "Last 90 days", length: 13, interval: :week, unit: :week),
22
+ Preset.new(key: "12m", label: "Last 12 months", length: 12, interval: :month, unit: :month),
23
+ Preset.new(key: "mtd", label: "Month to date", length: nil, interval: :day, unit: :day)
24
+ ].freeze
25
+
26
+ DEFAULT_PRESET = "7d"
27
+
28
+ INTERVAL_LABELS = { hour: "Hourly", day: "Daily", week: "Weekly", month: "Monthly" }.freeze
29
+
30
+ attr_reader :preset
31
+
32
+ def initialize(params = {}, today: nil)
33
+ params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
34
+ params = params.transform_keys(&:to_s)
35
+
36
+ @today = today || current_date
37
+ @preset = find_preset(params["range"])
38
+ @interval = coerce_interval(params["interval"])
39
+ @compare = compare_param?(params["compare"])
40
+ end
41
+
42
+ def range
43
+ @range ||= @preset.key == "mtd" ? (@today.beginning_of_month..@today) : trailing_range
44
+ end
45
+
46
+ def interval
47
+ @interval || @preset.interval
48
+ end
49
+
50
+ def compare?
51
+ @compare
52
+ end
53
+
54
+ def previous_range
55
+ return unless compare?
56
+
57
+ Interval.preceding(range, interval: interval)
58
+ end
59
+
60
+ def presets
61
+ PRESETS
62
+ end
63
+
64
+ def intervals
65
+ available = case @preset.unit
66
+ when :hour then %i[hour day]
67
+ when :month then %i[month]
68
+ when :week then %i[week month]
69
+ else %i[day week month]
70
+ end
71
+
72
+ available.map { |key| [INTERVAL_LABELS.fetch(key), key.to_s] }
73
+ end
74
+
75
+ def label
76
+ @preset.label
77
+ end
78
+
79
+ def interval_label
80
+ INTERVAL_LABELS.fetch(interval)
81
+ end
82
+
83
+ def summary
84
+ parts = [label, interval_label]
85
+ parts << "Compared to previous period" if compare?
86
+ parts
87
+ end
88
+
89
+ def to_params
90
+ { range: @preset.key, interval: interval.to_s, compare: compare? ? "1" : "0" }
91
+ end
92
+
93
+ private
94
+
95
+ def trailing_range
96
+ finish = @preset.unit == :hour ? current_time : @today
97
+ start = (@preset.length - 1).times.inject(finish) { |value, _| Interval.step_back(value, @preset.interval) }
98
+
99
+ start..finish
100
+ end
101
+
102
+ def find_preset(key)
103
+ PRESETS.find { |preset| preset.key == key.to_s } || PRESETS.find { |preset| preset.key == DEFAULT_PRESET }
104
+ end
105
+
106
+ # An explicit interval only sticks if it makes sense for the window: hourly
107
+ # buckets across twelve months is 8,760 points nobody can read.
108
+ def coerce_interval(value)
109
+ return if value.blank?
110
+
111
+ candidate = value.to_sym
112
+ allowed = intervals.map { |(_, key)| key.to_sym }
113
+ allowed.include?(candidate) ? candidate : nil
114
+ end
115
+
116
+ def compare_param?(value)
117
+ return true if value.nil?
118
+
119
+ %w[1 true yes on].include?(value.to_s.downcase)
120
+ end
121
+
122
+ def current_date
123
+ Time.respond_to?(:zone) && Time.zone ? Time.zone.today : Date.today
124
+ end
125
+
126
+ def current_time
127
+ Time.respond_to?(:zone) && Time.zone ? Time.zone.now : Time.now
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsuiCharts
4
+ # Date arithmetic for the supported bucket sizes: truncating a value to the
5
+ # bucket it belongs in, stepping between buckets, and finding the window
6
+ # before a window.
7
+ #
8
+ # This is plumbing rather than product. Filters needs it to resolve a preset
9
+ # into a range, so it stays here even though the bucketing that builds on it
10
+ # lives in Rails UI Charts Pro.
11
+ module Interval
12
+ SUPPORTED = %i[hour day week month].freeze
13
+
14
+ class << self
15
+ def validate!(interval)
16
+ interval = interval.to_sym
17
+ raise ArgumentError, "Unsupported interval: #{interval}. Supported: #{SUPPORTED.join(', ')}" unless SUPPORTED.include?(interval)
18
+
19
+ interval
20
+ end
21
+
22
+ def truncate(value, interval)
23
+ time = coerce(value)
24
+ return if time.nil?
25
+
26
+ case interval.to_sym
27
+ when :hour then time.change(min: 0, sec: 0)
28
+ when :week then time.to_date.beginning_of_week
29
+ when :month then time.to_date.beginning_of_month
30
+ else time.to_date
31
+ end
32
+ end
33
+
34
+ def coerce(value)
35
+ case value
36
+ when Time, DateTime then in_zone(value)
37
+ when Date then value
38
+ when String then parse(value)
39
+ when Numeric then in_zone(Time.at(value))
40
+ else value.respond_to?(:to_time) ? in_zone(value.to_time) : nil
41
+ end
42
+ end
43
+
44
+ # The equal-length window immediately before this one.
45
+ def preceding(range, interval: :day)
46
+ return if range.nil?
47
+
48
+ length = count(range, interval)
49
+ finish = step_back(range.first, interval)
50
+ start = length.pred.times.inject(finish) { |value, _| step_back(value, interval) }
51
+
52
+ start..finish
53
+ end
54
+
55
+ def count(range, interval)
56
+ total = 0
57
+ cursor = range.first
58
+
59
+ while cursor <= range.last
60
+ total += 1
61
+ cursor = step_forward(cursor, interval)
62
+ end
63
+
64
+ total
65
+ end
66
+
67
+ def step_forward(value, interval)
68
+ shift(value, interval, 1)
69
+ end
70
+
71
+ def step_back(value, interval)
72
+ shift(value, interval, -1)
73
+ end
74
+
75
+ # Every bucket start between the two ends, inclusive.
76
+ def each(range, interval)
77
+ return enum_for(:each, range, interval) unless block_given?
78
+
79
+ cursor = truncate(range.first, interval)
80
+ finish = truncate(range.last, interval)
81
+
82
+ while cursor <= finish
83
+ yield cursor
84
+ cursor = step_forward(cursor, interval)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def shift(value, interval, direction)
91
+ case interval.to_sym
92
+ when :hour then value + (direction * 3600)
93
+ when :week then value + (direction * 7)
94
+ when :month then value >> direction
95
+ else value + direction
96
+ end
97
+ end
98
+
99
+ def in_zone(time)
100
+ Time.respond_to?(:zone) && Time.zone ? time.in_time_zone : time
101
+ end
102
+
103
+ def parse(value)
104
+ Time.respond_to?(:zone) && Time.zone ? Time.zone.parse(value) : Time.parse(value)
105
+ rescue ArgumentError, TypeError
106
+ nil
107
+ end
108
+ end
109
+ end
110
+ end