Compare commits

..

3 Commits

Author SHA1 Message Date
887d669f80 Remove duplicate disable/enable logic and fetch() calls
readoutUnitChanged was manually setting disabled/aria-disabled/tabindex
attributes — duplicating Turbo.StreamElement.prototype.disableElement/
enableElement which already exists for this purpose. Replace with calls
to those methods.

Also replace fetch() in setDefaultUnit and drop with form.requestSubmit()
so Turbo handles CSRF, stream responses and lifecycle natively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:08:21 +00:00
366662a948 Replace JS-generated wide table with ERB partial and Turbo Streams
- Add _wide_table.html.erb partial (server-rendered pivot table)
- Add load_measurements helper in controller to prepare @wide_groups and
  @wide_quantities for all mutating actions
- Update index view to render the wide_table partial in #measurements-wide
- Add/update create, destroy, update turbo_stream views to refresh the
  wide table atomically after each mutation
- Remove buildWideTable() and editMeasurementWide() from application.js
- Fix create.turbo_stream.erb condition (empty readouts are vacuously all persisted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:28:32 +00:00
1bc75f5d40 Fix column header lookup fragility, add fetch error handling, add tests
- Replace position-based column header lookup (ths[3]/ths[4]) with
  data-column attribute selectors — immune to column reordering
- Add .catch() error handlers to editMeasurementWide and setDefaultUnit
  fetch calls so failures surface in the console instead of silently
  disappearing
- Add MeasurementsController integration tests covering index auth,
  create with taken_at, empty-readout create, destroy, cross-user
  destroy isolation, and update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:24:26 +00:00
18 changed files with 166 additions and 333 deletions

View File

@@ -111,11 +111,6 @@ svg {
svg:last-child { svg:last-child {
margin-right: 0; margin-right: 0;
} }
.chart-panel svg {
height: auto;
margin: 0;
width: auto;
}
textarea { textarea {
margin: 0; margin: 0;
} }
@@ -671,9 +666,6 @@ body[data-measurements-view=wide] .view-toggle[data-view=wide] {
color: white; color: white;
fill: white; fill: white;
} }
.chart-panel {
width: 100%;
}
#measurements tr.grouped td { #measurements tr.grouped td {
border-top: none; border-top: none;
} }

View File

@@ -1,12 +0,0 @@
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

@@ -6,7 +6,7 @@ class MeasurementsController < ApplicationController
end end
def index def index
@measurements = current_user.readouts.includes(:quantity, :unit).order(taken_at: :desc, id: :desc) load_measurements
end end
def new def new
@@ -20,6 +20,7 @@ class MeasurementsController < ApplicationController
if @readouts.present? && @readouts.all?(&:valid?) if @readouts.present? && @readouts.all?(&:valid?)
ActiveRecord::Base.transaction { @readouts.each(&:save!) } ActiveRecord::Base.transaction { @readouts.each(&:save!) }
load_measurements
flash.now[:notice] = t('.success', count: @readouts.size) flash.now[:notice] = t('.success', count: @readouts.size)
else else
errors = @readouts.flat_map { |r| r.errors.full_messages } errors = @readouts.flat_map { |r| r.errors.full_messages }
@@ -33,6 +34,7 @@ class MeasurementsController < ApplicationController
def update def update
if @readout.update(params.require(:readout).permit(:value, :unit_id, :taken_at)) if @readout.update(params.require(:readout).permit(:value, :unit_id, :taken_at))
load_measurements
flash.now[:notice] = t('.success') flash.now[:notice] = t('.success')
else else
@user_units = current_user.units.ordered @user_units = current_user.units.ordered
@@ -42,6 +44,7 @@ class MeasurementsController < ApplicationController
def destroy def destroy
@readout.destroy! @readout.destroy!
load_measurements
flash.now[:notice] = t('.success') flash.now[:notice] = t('.success')
end end
@@ -50,4 +53,10 @@ class MeasurementsController < ApplicationController
def find_readout def find_readout
@readout = current_user.readouts.find(params[:id]) @readout = current_user.readouts.find(params[:id])
end end
def load_measurements
@measurements = current_user.readouts.includes(:quantity, :unit).order(taken_at: :desc, id: :desc)
@wide_groups = @measurements.group_by(&:taken_at)
@wide_quantities = @measurements.map(&:quantity).uniq.sort_by(&:name)
end
end end

View File

@@ -171,7 +171,6 @@ 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

@@ -22,148 +22,6 @@ function groupMeasurements() {
}); });
} }
function buildWideTable() {
var tbody = document.getElementById('measurements');
var wideContainer = document.getElementById('measurements-wide');
if (!tbody || !wideContainer) return;
var rows = Array.from(tbody.querySelectorAll('tr[data-taken-at]'));
if (rows.length === 0) { wideContainer.innerHTML = ''; return; }
// Unique quantities in alphabetical order
var qOrder = [], qSeen = new Set();
rows.forEach(function(r) {
var id = r.dataset.quantityId;
if (id && !qSeen.has(id)) { qSeen.add(id); qOrder.push({id: id, name: r.dataset.quantityName}); }
});
qOrder.sort(function(a, b) { return a.name.localeCompare(b.name); });
// Group rows by taken_at, preserving order
var groups = [], groupMap = new Map();
rows.forEach(function(r) {
var key = r.dataset.takenAt || '';
if (!groupMap.has(key)) { var g = {rows: []}; groups.push(g); groupMap.set(key, g); }
groupMap.get(key).rows.push(r);
});
// Read column headers from compact thead
var ths = document.querySelectorAll('.measurements-compact thead th');
var takenAtHeader = ths[3] ? ths[3].textContent : '';
var createdAtHeader = ths[4] ? ths[4].textContent : '';
var table = document.createElement('table');
table.className = 'items-table';
// Header
var thead = table.createTHead();
var hrow = thead.insertRow();
[takenAtHeader].concat(qOrder.map(function(q) { return q.name; })).concat([createdAtHeader]).forEach(function(text) {
var th = document.createElement('th');
th.textContent = text;
hrow.appendChild(th);
});
// Body
var tbodyEl = table.createTBody();
groups.forEach(function(group) {
var tr = tbodyEl.insertRow();
// Taken at
var tdTime = tr.insertCell();
var takenAtEl = group.rows[0].querySelector('.taken-at');
tdTime.textContent = takenAtEl ? takenAtEl.textContent : '';
// One cell per quantity
qOrder.forEach(function(q) {
var td = tr.insertCell();
var readoutRow = group.rows.find(function(r) { return r.dataset.quantityId === q.id; });
if (readoutRow) {
td.className = 'ralign';
var wrap = document.createElement('span');
wrap.className = 'wide-cell';
var editLink = readoutRow.querySelector('a.link');
if (editLink) {
var editUrl = editLink.href + (editLink.href.includes('?') ? '&' : '?') + 'view=wide';
var btn = document.createElement('button');
btn.className = 'link';
btn.type = 'button';
btn.dataset.editUrl = editUrl;
btn.addEventListener('click', function() { editMeasurementWide(this.dataset.editUrl); this.blur(); });
btn.textContent = readoutRow.dataset.value;
wrap.appendChild(btn);
wrap.appendChild(document.createTextNode('\u00a0' + readoutRow.dataset.unit));
} else {
wrap.appendChild(document.createTextNode(readoutRow.dataset.value + '\u00a0' + readoutRow.dataset.unit));
}
var srcActions = readoutRow.querySelector('td.flex');
if (srcActions) srcActions.querySelectorAll('form').forEach(function(f) {
var cloned = f.cloneNode(true);
var span = cloned.querySelector('button span');
if (span) span.remove();
wrap.appendChild(cloned);
});
td.appendChild(wrap);
}
});
// Created at (from first row of group)
var tdCreated = tr.insertCell();
var createdAtEl = group.rows[0].querySelector('.created-at');
tdCreated.textContent = createdAtEl ? createdAtEl.textContent : '';
});
wideContainer.innerHTML = '';
wideContainer.appendChild(table);
}
function buildCharts() {
var container = document.getElementById('measurements-charts');
var dataEl = document.getElementById('charts-data');
if (!container || !dataEl) return;
var readouts = JSON.parse(dataEl.textContent);
container.innerHTML = '';
if (readouts.length === 0) return;
// Data arrives sorted by taken_at from the server; group into per-quantity traces
var quantities = new Map();
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(r.quantityId);
q.x.push(r.takenAt);
q.y.push(r.value);
});
var traces = [];
quantities.forEach(function(q) {
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() { function getMeasurementsView() {
return localStorage.getItem('measurements-view') || 'compact'; return localStorage.getItem('measurements-view') || 'compact';
@@ -171,7 +29,6 @@ function getMeasurementsView() {
function applyMeasurementsView(view) { function applyMeasurementsView(view) {
document.body.dataset.measurementsView = view; document.body.dataset.measurementsView = view;
if (view === 'wide') buildWideTable();
} }
function setMeasurementsView(view) { function setMeasurementsView(view) {
@@ -181,18 +38,12 @@ 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();
applyMeasurementsView(getMeasurementsView()); applyMeasurementsView(getMeasurementsView());
new MutationObserver(function() { new MutationObserver(function() {
groupMeasurements(); groupMeasurements();
var view = getMeasurementsView();
if (view === 'wide') buildWideTable();
}).observe(tbody, { }).observe(tbody, {
childList: true, subtree: true, childList: true, subtree: true,
attributes: true, attributeFilter: ['style'] attributes: true, attributeFilter: ['style']
@@ -227,62 +78,38 @@ window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true})) mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
}); });
function editMeasurementWide(url) {
fetch(url, {
headers: {
'Accept': 'text/vnd.turbo-stream.html',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.text())
.then(html => {
Turbo.renderStreamMessage(html);
requestAnimationFrame(() => {
var panel = document.getElementById('measurement_edit_form');
if (panel && panel.firstElementChild) {
panel.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
});
});
}
window.editMeasurementWide = editMeasurementWide
function readoutUnitChanged(select) { function readoutUnitChanged(select) {
var button = select.closest('tr').querySelector('.set-default-unit'); var button = select.closest('tr').querySelector('.set-default-unit');
if (select.value && select.value !== select.dataset.defaultUnitId) { if (select.value && select.value !== select.dataset.defaultUnitId) {
button.removeAttribute('disabled'); Turbo.StreamElement.prototype.enableElement(button);
button.removeAttribute('aria-disabled');
button.removeAttribute('tabindex');
} else { } else {
button.setAttribute('disabled', 'disabled'); Turbo.StreamElement.prototype.disableElement(button);
button.setAttribute('aria-disabled', 'true');
button.setAttribute('tabindex', '-1');
} }
} }
window.readoutUnitChanged = readoutUnitChanged window.readoutUnitChanged = readoutUnitChanged
function setDefaultUnit(button) { function setDefaultUnit(button) {
var select = button.closest('tr').querySelector('select[data-default-unit-id]'); var select = button.closest('tr').querySelector('select[data-default-unit-id]');
var params = new URLSearchParams(); var form = document.createElement('form');
params.append('quantity[default_unit_id]', select.value); form.action = button.dataset.path;
form.method = 'post';
fetch(button.dataset.path, { form.dataset.turboStream = 'true';
body: params, var methodInput = document.createElement('input');
headers: { methodInput.type = 'hidden'; methodInput.name = '_method'; methodInput.value = 'patch';
'Accept': 'text/vnd.turbo-stream.html', var unitInput = document.createElement('input');
'X-CSRF-Token': document.head.querySelector('meta[name=csrf-token]').content, unitInput.type = 'hidden'; unitInput.name = 'quantity[default_unit_id]'; unitInput.value = select.value;
'X-Requested-With': 'XMLHttpRequest' form.appendChild(methodInput);
}, form.appendChild(unitInput);
method: 'PATCH' form.addEventListener('turbo:submit-end', function(event) {
}) if (event.detail.success) {
.then(response => {
if (response.ok) {
select.dataset.defaultUnitId = select.value; select.dataset.defaultUnitId = select.value;
readoutUnitChanged(select); readoutUnitChanged(select);
} }
return response.text(); form.remove();
}) });
.then(html => Turbo.renderStreamMessage(html)); document.body.appendChild(form);
form.requestSubmit();
} }
window.setDefaultUnit = setDefaultUnit window.setDefaultUnit = setDefaultUnit
@@ -455,22 +282,17 @@ window.dragEnd = dragEnd
function drop(event) { function drop(event) {
event.preventDefault() event.preventDefault()
var idParam = event.currentTarget.getAttribute("data-drop-id-param")
var params = new URLSearchParams()
var id_param = event.currentTarget.getAttribute("data-drop-id-param")
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop() var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop()
params.append(id_param, id) var form = document.createElement('form');
form.action = event.dataTransfer.getData("text/plain");
fetch(event.dataTransfer.getData("text/plain"), { form.method = 'post';
body: params, form.dataset.turboStream = 'true';
headers: { var input = document.createElement('input');
"Accept": "text/vnd.turbo-stream.html", input.type = 'hidden'; input.name = idParam; input.value = id;
"X-CSRF-Token": document.head.querySelector("meta[name=csrf-token]").content, form.appendChild(input);
"X-Requested-With": "XMLHttpRequest" form.addEventListener('turbo:submit-end', function() { form.remove(); });
}, document.body.appendChild(form);
method: "POST" form.requestSubmit();
})
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html))
} }
window.drop = drop window.drop = drop

View File

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

View File

@@ -14,7 +14,6 @@
<%= csp_meta_tag %> <%= csp_meta_tag %>
<%= stylesheet_link_tag "spreadsheet" %> <%= stylesheet_link_tag "spreadsheet" %>
<script src="https://cdn.plot.ly/plotly-basic-2.35.2.min.js"></script>
<%= javascript_importmap_tags %> <%= javascript_importmap_tags %>
<%#= turbo_page_requires_reload_tag %> <%#= turbo_page_requires_reload_tag %>

View File

@@ -0,0 +1,41 @@
<table class="items-table">
<thead>
<tr>
<th><%= Readout.human_attribute_name(:taken_at) %></th>
<% wide_quantities.each do |q| %>
<th><%= q.name %></th>
<% end %>
<th><%= Readout.human_attribute_name(:created_at) %></th>
</tr>
</thead>
<tbody>
<% wide_groups.each do |taken_at, readouts| %>
<tr>
<td><%= l(taken_at) if taken_at %></td>
<% wide_quantities.each do |q| %>
<% readout = readouts.find { |r| r.quantity_id == q.id } %>
<td class="ralign">
<% if readout %>
<span class="wide-cell">
<% if current_user.at_least(:active) %>
<%= link_to format("%.10g", readout.value),
edit_measurement_path(readout, view: :wide),
class: 'link', onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% else %>
<%= format("%.10g", readout.value) %>
<% end %>
&nbsp;<%= readout.unit.symbol %>
<% if current_user.at_least(:active) %>
<%= image_button_to '', 'delete-outline', measurement_path(readout),
method: :delete, data: {turbo_stream: true} %>
<% end %>
</span>
<% end %>
</td>
<% end %>
<td><%= l(readouts.first.created_at) %></td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,13 @@
<% if @readouts.present? && @readouts.all?(&:persisted?) %>
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.enable :new_measurement_link %>
<%= turbo_stream.remove :no_items %>
<% @readouts.each do |readout| %>
<%= turbo_stream.prepend :measurements, partial: 'readout', locals: {readout: readout} %>
<% end %>
<%= turbo_stream.update 'measurements-wide', partial: 'wide_table',
locals: {wide_groups: @wide_groups, wide_quantities: @wide_quantities} %>
<% else %>
<%= turbo_stream.update :flashes %>
<% end %>

View File

@@ -0,0 +1,5 @@
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove @readout %>
<%= turbo_stream.append(:measurements, render_no_items) if current_user.readouts.empty? %>
<%= turbo_stream.update 'measurements-wide', partial: 'wide_table',
locals: {wide_groups: @wide_groups, wide_quantities: @wide_quantities} %>

View File

@@ -11,7 +11,6 @@
<%= 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')" %>
</div> </div>
<div class="main-area measurements-section"> <div class="main-area measurements-section">
@@ -22,8 +21,8 @@
<th><%= Quantity.model_name.human %></th> <th><%= Quantity.model_name.human %></th>
<th><%= Readout.human_attribute_name(:value) %></th> <th><%= Readout.human_attribute_name(:value) %></th>
<th><%= Unit.model_name.human %></th> <th><%= Unit.model_name.human %></th>
<th><%= Readout.human_attribute_name(:taken_at) %></th> <th data-column="taken-at"><%= Readout.human_attribute_name(:taken_at) %></th>
<th><%= Readout.human_attribute_name(:created_at) %></th> <th data-column="created-at"><%= Readout.human_attribute_name(:created_at) %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th></th> <th></th>
<% end %> <% end %>
@@ -34,7 +33,8 @@
</tbody> </tbody>
</table> </table>
<div id="measurements-wide" class="measurements-wide"></div> <div id="measurements-wide" class="measurements-wide">
<%= render 'wide_table', wide_groups: @wide_groups, wide_quantities: @wide_quantities %>
</div>
</div> </div>

View File

@@ -1,2 +1,4 @@
<%= turbo_stream.close_form dom_id(@readout, :edit) %> <%= turbo_stream.close_form dom_id(@readout, :edit) %>
<%= turbo_stream.replace @readout, partial: 'measurements/readout', locals: {readout: @readout} %> <%= turbo_stream.replace @readout, partial: 'measurements/readout', locals: {readout: @readout} %>
<%= turbo_stream.update 'measurements-wide', partial: 'wide_table',
locals: {wide_groups: @wide_groups, wide_quantities: @wide_quantities} %>

View File

@@ -89,8 +89,6 @@ 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.
@@ -101,7 +99,6 @@ en:
new_measurement: Add measurement new_measurement: Add measurement
view_compact: Compact view view_compact: Compact view
view_wide: Wide view view_wide: Wide view
view_charts: Charts
readout: readout:
edit: Edit edit: Edit
destroy: Delete destroy: Delete

View File

@@ -1,6 +1,5 @@
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

@@ -1,63 +0,0 @@
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

@@ -1,7 +1,65 @@
require "test_helper" require "test_helper"
class MeasurementsControllerTest < ActionDispatch::IntegrationTest class MeasurementsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do setup do
# assert true host! '127.0.0.1'
# end @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 "index returns ok" do
get measurements_path
assert_response :success
end
test "index requires authentication" do
delete destroy_user_session_path
get measurements_path
assert_response :redirect
end
test "create records readout with taken_at" do
taken_at = 1.day.ago.change(usec: 0)
assert_difference -> { @user.readouts.count } do
post measurements_path, params: {
taken_at: taken_at.iso8601,
readouts: [{ quantity_id: @quantity.id, value: '82.5', unit_id: @unit.id }]
}, as: :turbo_stream
end
assert_response :success
assert_equal taken_at, @user.readouts.last.taken_at
end
test "create with no readouts selected shows alert" do
post measurements_path, params: { taken_at: Time.now.iso8601 }, as: :turbo_stream
assert_response :success
end
test "destroy removes readout" do
readout = @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago)
assert_difference -> { @user.readouts.count }, -1 do
delete measurement_path(readout), as: :turbo_stream
end
assert_response :success
end
test "destroy cannot remove another user's readout" do
other_quantity = users(:bob).quantities.create!(name: 'Weight')
other_unit = users(:bob).units.create!(symbol: 'kg')
readout = users(:bob).readouts.create!(quantity: other_quantity, unit: other_unit, value: 70.0, taken_at: 1.day.ago)
assert_no_difference -> { users(:bob).readouts.count } do
delete measurement_path(readout), as: :turbo_stream
end
end
test "update changes readout value" do
readout = @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago)
patch measurement_path(readout), params: {
readout: { value: '83.0', unit_id: @unit.id, taken_at: readout.taken_at.iso8601 }
}, as: :turbo_stream
assert_response :success
assert_in_delta 83.0, readout.reload.value
end
end end

View File

@@ -1,26 +0,0 @@
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