diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 581a509..4bb8ccf 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -661,29 +661,18 @@ li::marker { overflow-x: auto; } body[data-measurements-view=wide] .measurements-compact, -body[data-measurements-view=wide] .measurements-charts, -body[data-measurements-view=compact] .measurements-wide, -body[data-measurements-view=compact] .measurements-charts, -body[data-measurements-view=charts] .measurements-compact, -body[data-measurements-view=charts] .measurements-wide { +body[data-measurements-view=compact] .measurements-wide { display: none; } body[data-measurements-view=compact] .view-toggle[data-view=compact], -body[data-measurements-view=wide] .view-toggle[data-view=wide], -body[data-measurements-view=charts] .view-toggle[data-view=charts] { +body[data-measurements-view=wide] .view-toggle[data-view=wide] { background-color: var(--color-blue); border-color: var(--color-blue); color: white; fill: white; } -.measurements-charts { - display: flex; - flex-wrap: wrap; - gap: 1em; -} .chart-panel { - flex: 1 1 400px; - min-width: 300px; + width: 100%; } #measurements tr.grouped td { border-top: none; diff --git a/app/controllers/charts_controller.rb b/app/controllers/charts_controller.rb new file mode 100644 index 0000000..b1944cb --- /dev/null +++ b/app/controllers/charts_controller.rb @@ -0,0 +1,12 @@ +class ChartsController < ApplicationController + def index + readouts = current_user.readouts.includes(:quantity, :unit).order(:taken_at, :id) + @readouts_json = readouts.map { |r| + { takenAt: r.taken_at&.iso8601, + quantityId: r.quantity_id, + quantityName: r.quantity.name, + value: r.value.to_f, + unit: r.unit.symbol } + }.to_json + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0dea454..2b63650 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -171,6 +171,7 @@ module ApplicationHelper ['measurements', 'scale-bathroom', :restricted], ['quantities', 'axis-arrow', :restricted, 'right'], ['units', 'weight-gram', :restricted], + ['charts', 'chart-line', :restricted], # TODO: display users tab only if >1 user present; sole_user?/sole_admin? ['users', 'account-multiple-outline', :admin], ] diff --git a/app/javascript/application.js b/app/javascript/application.js index bcea64f..df6c426 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -118,57 +118,51 @@ function buildWideTable() { } function buildCharts() { - var tbody = document.getElementById('measurements'); var container = document.getElementById('measurements-charts'); - if (!tbody || !container) return; + var dataEl = document.getElementById('charts-data'); + if (!container || !dataEl) return; - var rows = Array.from(tbody.querySelectorAll('tr[data-taken-at]')); + var readouts = JSON.parse(dataEl.textContent); container.innerHTML = ''; - if (rows.length === 0) return; + if (readouts.length === 0) return; - // Collect data per quantity, preserving insertion order + // Data arrives sorted by taken_at from the server; group into per-quantity traces var quantities = new Map(); - rows.forEach(function(r) { - var qid = r.dataset.quantityId; - if (!qid) return; - if (!quantities.has(qid)) { - quantities.set(qid, { name: r.dataset.quantityName, unit: r.dataset.unit, x: [], y: [] }); + readouts.forEach(function(r) { + if (!r.takenAt) return; + if (!quantities.has(r.quantityId)) { + quantities.set(r.quantityId, { name: r.quantityName, unit: r.unit, x: [], y: [] }); } - var q = quantities.get(qid); - q.x.push(r.dataset.takenAt); - q.y.push(parseFloat(r.dataset.value)); + var q = quantities.get(r.quantityId); + q.x.push(r.takenAt); + q.y.push(r.value); }); + var traces = []; quantities.forEach(function(q) { - // Sort by time ascending for the line - var pairs = q.x.map(function(x, i) { return [x, q.y[i]]; }); - pairs.sort(function(a, b) { return a[0] < b[0] ? -1 : 1; }); - - var div = document.createElement('div'); - div.className = 'chart-panel'; - container.appendChild(div); - - (function(el, name, unit, data) { - Plotly.newPlot(el, [{ - x: data.map(function(p) { return p[0]; }), - y: data.map(function(p) { return p[1]; }), - mode: 'lines+markers', - type: 'scatter', - name: name, - line: { color: '#009ade' }, - marker: { color: '#009ade', size: 6 } - }], { - title: { text: name, font: { size: 14 } }, - height: 280, - xaxis: { type: 'date', tickformat: '%Y-%m-%d' }, - yaxis: { title: { text: unit } }, - margin: { t: 40, r: 20, b: 60, l: 60 }, - paper_bgcolor: 'transparent', - plot_bgcolor: 'transparent', - font: { family: 'system-ui' } - }, { responsive: true, displayModeBar: false }); - }(div, q.name, q.unit, pairs)); + traces.push({ + x: q.x, + y: q.y, + mode: 'lines+markers', + type: 'scatter', + name: q.name + ' (' + q.unit + ')', + marker: { size: 5 } + }); }); + + var div = document.createElement('div'); + div.className = 'chart-panel'; + container.appendChild(div); + + Plotly.newPlot(div, traces, { + xaxis: { type: 'date', tickformat: '%Y-%m-%d %H:%M' }, + yaxis: {}, + margin: { t: 20, r: 20, b: 80, l: 60 }, + paper_bgcolor: 'transparent', + plot_bgcolor: 'transparent', + font: { family: 'system-ui' }, + legend: { orientation: 'h', y: -0.25 } + }, { responsive: true, displayModeBar: false }); } function getMeasurementsView() { @@ -178,7 +172,6 @@ function getMeasurementsView() { function applyMeasurementsView(view) { document.body.dataset.measurementsView = view; if (view === 'wide') buildWideTable(); - if (view === 'charts') buildCharts(); } function setMeasurementsView(view) { @@ -188,6 +181,10 @@ function setMeasurementsView(view) { window.setMeasurementsView = setMeasurementsView document.addEventListener('turbo:load', function() { + if (document.getElementById('charts-data')) { + buildCharts(); + return; + } var tbody = document.getElementById('measurements'); if (!tbody) return; groupMeasurements(); @@ -196,7 +193,6 @@ document.addEventListener('turbo:load', function() { groupMeasurements(); var view = getMeasurementsView(); if (view === 'wide') buildWideTable(); - if (view === 'charts') buildCharts(); }).observe(tbody, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] diff --git a/app/views/charts/index.html.erb b/app/views/charts/index.html.erb new file mode 100644 index 0000000..18959b7 --- /dev/null +++ b/app/views/charts/index.html.erb @@ -0,0 +1,2 @@ +
+ diff --git a/app/views/measurements/index.html.erb b/app/views/measurements/index.html.erb index 5d4d00c..fc1e4c5 100644 --- a/app/views/measurements/index.html.erb +++ b/app/views/measurements/index.html.erb @@ -11,9 +11,7 @@ <%= image_button_tag '', 'view-columns', name: nil, type: 'button', class: 'view-toggle', title: t('.view_wide'), data: {view: 'wide'}, onclick: "setMeasurementsView('wide')" %> - <%= image_button_tag '', 'chart-line', name: nil, type: 'button', - class: 'view-toggle', title: t('.view_charts'), - data: {view: 'charts'}, onclick: "setMeasurementsView('charts')" %> +
@@ -37,6 +35,6 @@
-
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index d5589c0..355e768 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -89,6 +89,8 @@ en: readouts: form: set_default_unit: Set as default unit + charts: + navigation: Charts measurements: navigation: Measurements no_items: There are no measurements taken. You can Add some now. diff --git a/config/routes.rb b/config/routes.rb index 1d3f7b3..997eae2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ Rails.application.routes.draw do resources :measurements + resources :charts, only: [:index] resources :readouts, only: [:new] do collection {get 'new/:id/discard', action: :discard, as: :discard} diff --git a/test/controllers/charts_controller_test.rb b/test/controllers/charts_controller_test.rb new file mode 100644 index 0000000..d201b1e --- /dev/null +++ b/test/controllers/charts_controller_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class ChartsControllerTest < ActionDispatch::IntegrationTest + setup do + host! '127.0.0.1' + @user = users(:alice) + post new_user_session_path, params: { user: { email: @user.email, password: 'alice' } } + @quantity = @user.quantities.create!(name: 'Weight') + @unit = @user.units.create!(symbol: 'kg') + end + + test "requires authentication" do + delete destroy_user_session_path + get charts_path + assert_response :redirect + end + + test "index returns ok" do + get charts_path + assert_response :success + end + + test "embeds readout data as JSON in script tag" do + users(:alice).readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago) + + get charts_path + + assert_select 'script#charts-data[type="application/json"]' do |elements| + data = JSON.parse(elements.first.children.first.to_s) + assert_equal 1, data.size + assert_equal 'Weight', data.first['quantityName'] + assert_in_delta 82.5, data.first['value'] + assert_equal 'kg', data.first['unit'] + assert_not_nil data.first['takenAt'] + end + end + + test "orders readouts by taken_at ascending" do + older = users(:alice).readouts.create!(quantity: @quantity, unit: @unit, value: 80.0, taken_at: 2.days.ago) + newer = users(:alice).readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago) + + get charts_path + + assert_select 'script#charts-data[type="application/json"]' do |elements| + data = JSON.parse(elements.first.children.first.to_s) + assert_equal older.taken_at.iso8601, data.first['takenAt'] + assert_equal newer.taken_at.iso8601, data.last['takenAt'] + end + end + + test "does not expose other users readouts" do + bob_quantity = users(:bob).quantities.create!(name: 'Steps') + bob_unit = users(:bob).units.create!(symbol: 'steps') + users(:bob).readouts.create!(quantity: bob_quantity, unit: bob_unit, value: 5000, taken_at: 1.day.ago) + + get charts_path + + assert_select 'script#charts-data[type="application/json"]' do |elements| + data = JSON.parse(elements.first.children.first.to_s) + assert data.none? { |r| r['quantityName'] == 'Steps' }, "Bob's data must not appear" + end + end +end diff --git a/test/system/charts_test.rb b/test/system/charts_test.rb new file mode 100644 index 0000000..62ca9da --- /dev/null +++ b/test/system/charts_test.rb @@ -0,0 +1,26 @@ +require "application_system_test_case" + +class ChartsTest < ApplicationSystemTestCase + setup do + @user = sign_in(user: users(:alice)) + @quantity = @user.quantities.create!(name: 'Weight') + @unit = @user.units.create!(symbol: 'kg') + @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago) + @user.readouts.create!(quantity: @quantity, unit: @unit, value: 83.1, taken_at: Time.now) + visit charts_path + end + + test "charts page is reachable from navigation" do + visit root_path + click_on t('charts.navigation') + assert_current_path charts_path + end + + test "renders Plotly chart panel" do + assert_selector '#measurements-charts .chart-panel', wait: 5 + end + + test "chart legend shows quantity name with unit" do + assert_text 'Weight (kg)', wait: 5 + end +end