forked from fixin.me/fixin.me
Refactor charts: dedicated nav tab, JSON data transport, tests
Replace the toggle-view approach and hidden DOM data carrier with a proper dedicated Charts page: - Move Charts out of Measurements view toggles into its own nav tab and route (GET /charts) - ChartsController serializes readout data as JSON (ordered by taken_at); the view embeds it in a <script type="application/json"> element instead of rendering a hidden copy of the measurements partial just to ferry data attributes to JS - buildCharts() reads from the JSON element directly — no DOM parsing, no sorting in JS (server already orders the data) - Turbo load handler detects the charts page via #charts-data presence - Add controller tests (authentication, data shape, ordering, data isolation between users) and system tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
12
app/controllers/charts_controller.rb
Normal file
12
app/controllers/charts_controller.rb
Normal 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
|
||||||
@@ -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],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
||||||
|
type: 'scatter',
|
||||||
|
name: q.name + ' (' + q.unit + ')',
|
||||||
|
marker: { size: 5 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.className = 'chart-panel';
|
div.className = 'chart-panel';
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
|
||||||
(function(el, name, unit, data) {
|
Plotly.newPlot(div, traces, {
|
||||||
Plotly.newPlot(el, [{
|
xaxis: { type: 'date', tickformat: '%Y-%m-%d %H:%M' },
|
||||||
x: data.map(function(p) { return p[0]; }),
|
yaxis: {},
|
||||||
y: data.map(function(p) { return p[1]; }),
|
margin: { t: 20, r: 20, b: 80, l: 60 },
|
||||||
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',
|
paper_bgcolor: 'transparent',
|
||||||
plot_bgcolor: 'transparent',
|
plot_bgcolor: 'transparent',
|
||||||
font: { family: 'system-ui' }
|
font: { family: 'system-ui' },
|
||||||
|
legend: { orientation: 'h', y: -0.25 }
|
||||||
}, { responsive: true, displayModeBar: false });
|
}, { responsive: true, displayModeBar: false });
|
||||||
}(div, q.name, q.unit, pairs));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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']
|
||||||
|
|||||||
2
app/views/charts/index.html.erb
Normal file
2
app/views/charts/index.html.erb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<div class="main-area" id="measurements-charts"></div>
|
||||||
|
<script id="charts-data" type="application/json"><%= raw @readouts_json %></script>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
63
test/controllers/charts_controller_test.rb
Normal file
63
test/controllers/charts_controller_test.rb
Normal 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
|
||||||
26
test/system/charts_test.rb
Normal file
26
test/system/charts_test.rb
Normal 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
|
||||||
Reference in New Issue
Block a user