Merge feature/measurements-charts (refactored)

This commit is contained in:
2026-04-04 09:55:45 +00:00
10 changed files with 151 additions and 61 deletions

View File

@@ -661,29 +661,18 @@ li::marker {
overflow-x: auto; overflow-x: auto;
} }
body[data-measurements-view=wide] .measurements-compact, 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-wide,
body[data-measurements-view=compact] .measurements-charts,
body[data-measurements-view=charts] .measurements-compact,
body[data-measurements-view=charts] .measurements-wide {
display: none; display: none;
} }
body[data-measurements-view=compact] .view-toggle[data-view=compact], body[data-measurements-view=compact] .view-toggle[data-view=compact],
body[data-measurements-view=wide] .view-toggle[data-view=wide], body[data-measurements-view=wide] .view-toggle[data-view=wide] {
body[data-measurements-view=charts] .view-toggle[data-view=charts] {
background-color: var(--color-blue); background-color: var(--color-blue);
border-color: var(--color-blue); border-color: var(--color-blue);
color: white; color: white;
fill: white; fill: white;
} }
.measurements-charts {
display: flex;
flex-wrap: wrap;
gap: 1em;
}
.chart-panel { .chart-panel {
flex: 1 1 400px; width: 100%;
min-width: 300px;
} }
#measurements tr.grouped td { #measurements tr.grouped td {
border-top: none; border-top: none;

View File

@@ -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

View File

@@ -171,6 +171,7 @@ module ApplicationHelper
['measurements', 'scale-bathroom', :restricted], ['measurements', 'scale-bathroom', :restricted],
['quantities', 'axis-arrow', :restricted, 'right'], ['quantities', 'axis-arrow', :restricted, 'right'],
['units', 'weight-gram', :restricted], ['units', 'weight-gram', :restricted],
['charts', 'chart-line', :restricted],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin? # TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin], ['users', 'account-multiple-outline', :admin],
] ]

View File

@@ -118,57 +118,51 @@ function buildWideTable() {
} }
function buildCharts() { function buildCharts() {
var tbody = document.getElementById('measurements');
var container = document.getElementById('measurements-charts'); 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 = ''; 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(); var quantities = new Map();
rows.forEach(function(r) { readouts.forEach(function(r) {
var qid = r.dataset.quantityId; if (!r.takenAt) return;
if (!qid) return; if (!quantities.has(r.quantityId)) {
if (!quantities.has(qid)) { quantities.set(r.quantityId, { name: r.quantityName, unit: r.unit, x: [], y: [] });
quantities.set(qid, { name: r.dataset.quantityName, unit: r.dataset.unit, x: [], y: [] });
} }
var q = quantities.get(qid); var q = quantities.get(r.quantityId);
q.x.push(r.dataset.takenAt); q.x.push(r.takenAt);
q.y.push(parseFloat(r.dataset.value)); q.y.push(r.value);
}); });
var traces = [];
quantities.forEach(function(q) { quantities.forEach(function(q) {
// Sort by time ascending for the line traces.push({
var pairs = q.x.map(function(x, i) { return [x, q.y[i]]; }); x: q.x,
pairs.sort(function(a, b) { return a[0] < b[0] ? -1 : 1; }); y: q.y,
mode: 'lines+markers',
var div = document.createElement('div'); type: 'scatter',
div.className = 'chart-panel'; name: q.name + ' (' + q.unit + ')',
container.appendChild(div); marker: { size: 5 }
});
(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));
}); });
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() { function getMeasurementsView() {
@@ -178,7 +172,6 @@ function getMeasurementsView() {
function applyMeasurementsView(view) { function applyMeasurementsView(view) {
document.body.dataset.measurementsView = view; document.body.dataset.measurementsView = view;
if (view === 'wide') buildWideTable(); if (view === 'wide') buildWideTable();
if (view === 'charts') buildCharts();
} }
function setMeasurementsView(view) { function setMeasurementsView(view) {
@@ -188,6 +181,10 @@ function setMeasurementsView(view) {
window.setMeasurementsView = setMeasurementsView window.setMeasurementsView = setMeasurementsView
document.addEventListener('turbo:load', function() { document.addEventListener('turbo:load', function() {
if (document.getElementById('charts-data')) {
buildCharts();
return;
}
var tbody = document.getElementById('measurements'); var tbody = document.getElementById('measurements');
if (!tbody) return; if (!tbody) return;
groupMeasurements(); groupMeasurements();
@@ -196,7 +193,6 @@ document.addEventListener('turbo:load', function() {
groupMeasurements(); groupMeasurements();
var view = getMeasurementsView(); var view = getMeasurementsView();
if (view === 'wide') buildWideTable(); if (view === 'wide') buildWideTable();
if (view === 'charts') buildCharts();
}).observe(tbody, { }).observe(tbody, {
childList: true, subtree: true, childList: true, subtree: true,
attributes: true, attributeFilter: ['style'] attributes: true, attributeFilter: ['style']

View File

@@ -0,0 +1,2 @@
<div class="main-area" id="measurements-charts"></div>
<script id="charts-data" type="application/json"><%= raw @readouts_json %></script>

View File

@@ -11,9 +11,7 @@
<%= image_button_tag '', 'view-columns', name: nil, type: 'button', <%= image_button_tag '', 'view-columns', name: nil, type: 'button',
class: 'view-toggle', title: t('.view_wide'), class: 'view-toggle', title: t('.view_wide'),
data: {view: 'wide'}, onclick: "setMeasurementsView('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')" %>
</div> </div>
<div class="main-area measurements-section"> <div class="main-area measurements-section">
@@ -37,6 +35,6 @@
</table> </table>
<div id="measurements-wide" class="measurements-wide"></div> <div id="measurements-wide" class="measurements-wide"></div>
<div id="measurements-charts" class="measurements-charts"></div>
</div> </div>

View File

@@ -89,6 +89,8 @@ en:
readouts: readouts:
form: form:
set_default_unit: Set as default unit set_default_unit: Set as default unit
charts:
navigation: Charts
measurements: measurements:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now. no_items: There are no measurements taken. You can Add some now.

View File

@@ -1,5 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :measurements resources :measurements
resources :charts, only: [:index]
resources :readouts, only: [:new] do resources :readouts, only: [:new] do
collection {get 'new/:id/discard', action: :discard, as: :discard} collection {get 'new/:id/discard', action: :discard, as: :discard}

View File

@@ -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

View File

@@ -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