Class: CafeCar::ChartBuilder

Inherits:
Object
  • Object
show all
Defined in:
lib/cafe_car/chart_builder.rb

Overview

Aggregates an index collection into time buckets for the index "chart" view. Given a datetime/date column (the x-axis) and a granularity it GROUP BYs a DB-portable truncation of that column and COUNTs each bucket, then renders an inline SVG bar chart. The collection passed in is already policy-scoped and filtered (it is the same relation the table view renders), so the chart never counts rows the user can't see and always honors the active filters.

Constant Summary collapse

FORMATS =

strftime patterns, shared by SQLite's strftime and Ruby's Time#strftime so a bucket label is identical whichever adapter produced the key.

{ day: "%Y-%m-%d", week: "%Y-%W", month: "%Y-%m" }.freeze
DEFAULT_BUCKET =
:month
OPERATIONS =

The y-axis aggregations. count is a plain row count (no column); sum/avg aggregate a numeric column and map to the portable ActiveRecord calculation.

{ "sum" => :sum, "avg" => :average }.freeze
NUMERIC_TYPES =
%i[integer decimal float].freeze
DEFAULT_METRIC =
"count"
METRIC_DEFAULTS =
{ "sum" => "Sum of %{attribute}", "avg" => "Average %{attribute}" }.freeze
WIDTH =

SVG geometry (user units; the viewBox scales it to fit its container). WIDTH and HEIGHT are FIXED so the chart keeps one landscape shape at ANY bucket count. The CSS gives .Chart a full-width block with height: auto, so the rendered height follows the viewBox aspect ratio — a column-per-bucket layout made that viewBox near-square for a few buckets (rendered tall and narrow) and very wide for many (rendered flat and short). Distributing the bars across a fixed WIDTH instead holds a steady ~3.5:1 landscape that always fills the column.

1000
HEIGHT =
280
PAD_TOP =

room for the value label above the tallest bar

14
PAD_BOTTOM =

room for the x-axis (bucket) labels

22
MAX_BAR =

cap bar width so a handful of buckets don't balloon

72
BAR_RATIO =

bar width as a fraction of its slot

0.6

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(template, objects:, column: nil, bucket: nil, metric: nil) ⇒ ChartBuilder

Returns a new instance of ChartBuilder.



37
38
39
40
41
42
43
# File 'lib/cafe_car/chart_builder.rb', line 37

def initialize(template, objects:, column: nil, bucket: nil, metric: nil)
  @template = template
  @objects  = objects
  @column   = pick_column(column)
  @bucket   = FORMATS.key?(bucket.to_s.to_sym) ? bucket.to_s.to_sym : DEFAULT_BUCKET
  @metric   = pick_metric(metric)
end

Instance Attribute Details

#bucketObject (readonly)

Returns the value of attribute bucket.



45
46
47
# File 'lib/cafe_car/chart_builder.rb', line 45

def bucket
  @bucket
end

#columnObject (readonly)

Returns the value of attribute column.



45
46
47
# File 'lib/cafe_car/chart_builder.rb', line 45

def column
  @column
end

#metricObject (readonly)

Returns the value of attribute metric.



45
46
47
# File 'lib/cafe_car/chart_builder.rb', line 45

def metric
  @metric
end

Instance Method Details

#bucket_optionsObject



70
# File 'lib/cafe_car/chart_builder.rb', line 70

def bucket_options = FORMATS.keys.map { [ _1.to_s.capitalize, _1.to_s ] }

#column_optionsObject



69
# File 'lib/cafe_car/chart_builder.rb', line 69

def column_options = columns.map { [ info(_1).label, _1 ] }

#columnsObject

The date/datetime columns offered as x-axis choices: the model's displayable attributes (policy-respecting) whose type is a date. Column NAMES only reach the query through this allowlist — a ?chart_x= param outside it is dropped by #pick_column, so a raw param can never be interpolated as a column name.



51
52
53
54
55
56
# File 'lib/cafe_car/chart_builder.rb', line 51

def columns
  @columns ||= policy.attributes.displayable
                     .map    { info(_1) }
                     .select { _1.type.in?(%i[date datetime]) }
                     .map    { _1.method.to_s }
end

#dataObject

Ordered { "2026-01" => count } over the policy-scoped, filtered collection.



92
# File 'lib/cafe_car/chart_builder.rb', line 92

def data = @data ||= aggregate

#html_safe?Boolean

Returns:

  • (Boolean)


94
# File 'lib/cafe_car/chart_builder.rb', line 94

def html_safe? = true

#metric_label(op, attribute) ⇒ Object



87
88
89
# File 'lib/cafe_car/chart_builder.rb', line 87

def metric_label(op, attribute)
  t("chart.metric.#{op}", attribute:, default: METRIC_DEFAULTS.fetch(op) % { attribute: })
end

#metric_optionsObject

[label, encoding] pairs for the y-metric select: a plain count plus a sum + average per numeric column. Labels come from the locale.



78
79
80
81
82
83
84
85
# File 'lib/cafe_car/chart_builder.rb', line 78

def metric_options
  count = [ t("chart.metric.count", default: "Count"), DEFAULT_METRIC ]
  aggs  = value_columns.flat_map do |col|
    attribute = info(col).label
    OPERATIONS.each_key.map { |op| [ metric_label(op, attribute), "#{op}:#{col}" ] }
  end
  [ count, *aggs ]
end

#metrics?Boolean

Whether a y-metric selector is worth showing — only when there's a numeric column to aggregate (otherwise count is the only choice).

Returns:

  • (Boolean)


74
# File 'lib/cafe_car/chart_builder.rb', line 74

def metrics? = value_columns.any?

#to_sObject



95
# File 'lib/cafe_car/chart_builder.rb', line 95

def to_s       = svg.to_s

#value_columnsObject

The numeric columns offered as y-axis aggregates: the model's displayable attributes (policy-respecting, the SAME source of truth as the x-axis) whose type is numeric. A chart_y=sum:<col> param's column is validated against this list by #pick_metric, so a raw param never becomes a column reference.



62
63
64
65
66
67
# File 'lib/cafe_car/chart_builder.rb', line 62

def value_columns
  @value_columns ||= policy.attributes.displayable
                           .map    { info(_1) }
                           .select { _1.type.in?(NUMERIC_TYPES) }
                           .map    { _1.method.to_s }
end

#~@Object



96
# File 'lib/cafe_car/chart_builder.rb', line 96

def ~@         = @template.concat(to_s)