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')" %> +