Compare commits

..

17 Commits

Author SHA1 Message Date
4f10a4fcf8 Replace fetch() calls with Turbo form submission via requestSubmit()
setDefaultUnit and drop previously made raw fetch() requests and called
Turbo.renderStreamMessage() manually. Now both create a temporary <form>,
append hidden inputs, and call form.requestSubmit() — Turbo intercepts
the submission natively, handling CSRF, stream responses, and lifecycle.

Also document this convention in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:45:04 +00:00
652f9c0f34 Merge feature/measurements-wide-view into demo/example-data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:30:13 +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
481f509004 Merge feature/measurements-wide-view (fixes: data-column, fetch error handling, controller tests) 2026-04-04 10:24:53 +00:00
d1e718137d Merge feature/quantity-default-unit-and-taken-at (add taken_at index) 2026-04-04 10:24:53 +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
862430e586 Add index on readouts(user_id, taken_at)
MeasurementsController#index orders by taken_at desc; without an index
this scan grows linearly with the readout count. The composite index
on (user_id, taken_at) covers both the implicit user_id filter from
the association scope and the ORDER BY clause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:20:55 +00:00
d7f8ff4464 Merge feature/measurements-charts (refactored) 2026-04-04 09:55:45 +00:00
b78f3bc9bf Merge feature/measurements-charts 2026-04-03 22:50:47 +00:00
8e1cee03d0 Merge feature/measurements-wide-view
# Conflicts:
#	app/controllers/measurements_controller.rb
#	app/views/measurements/_readout.html.erb
#	app/views/measurements/index.html.erb
2026-04-03 22:50:42 +00:00
93850c386c Merge feature/quantity-default-unit-and-taken-at
# Conflicts:
#	config/locales/en.yml
2026-04-03 22:49:45 +00:00
207cc9f377 Merge branch 'fix/measurements-create-destroy' into demo/example-data 2026-04-02 16:48:58 +00:00
55a29b0920 Fix partial lookup for Readout objects in measurements views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:48:58 +00:00
af340d5859 Fix partial lookup for Readout objects in measurements views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:48:52 +00:00
599f9af01b Merge branch 'fix/measurements-create-destroy' into demo/example-data 2026-04-02 16:41:57 +00:00
cd5bac6cae Add demo user with 60 days of example health tracking data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:37:16 +00:00
5206323d06 Implement measurements create/destroy and display existing readouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:20:59 +00:00
13 changed files with 276 additions and 157 deletions

View File

@@ -77,6 +77,28 @@ default/ namespace for default units import/export and admin panel
root → /units (authenticated), /sign_in (unauthenticated) root → /units (authenticated), /sign_in (unauthenticated)
``` ```
## JavaScript Conventions
### No manual fetch() — use Turbo
Never make AJAX requests with `fetch()` in JavaScript. Use Turbo's built-in mechanisms instead:
- **Links/buttons that trigger server actions**: use `data: {turbo_stream: true}` on the element (link or button_to form).
- **Dynamic form submissions from JS** (where HTML alone isn't enough): create a form element, append hidden inputs, and call `form.requestSubmit()`. Turbo intercepts it automatically — no manual CSRF handling, no `Turbo.renderStreamMessage()`.
```javascript
var form = document.createElement('form');
form.action = url; form.method = 'post'; form.dataset.turboStream = 'true';
// append hidden inputs...
form.addEventListener('turbo:submit-end', function() { form.remove(); });
document.body.appendChild(form);
form.requestSubmit();
```
- **Server-rendered HTML**: use ERB partials and Turbo Stream views (`*.turbo_stream.erb`), never build HTML in JavaScript.
### No HTML generation in JavaScript
Never use JavaScript to build and insert HTML (no `innerHTML =`, no `createElement` trees for content). Render HTML server-side in ERB partials; update the DOM via Turbo Stream actions (`replace`, `update`, `append`, etc.).
## Database Requirements ## Database Requirements
The database must support: The database must support:

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

@@ -22,100 +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() { function buildCharts() {
var container = document.getElementById('measurements-charts'); var container = document.getElementById('measurements-charts');
@@ -171,7 +77,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) {
@@ -191,8 +96,6 @@ document.addEventListener('turbo:load', function() {
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,25 +130,6 @@ 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');
@@ -263,26 +147,25 @@ 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 +338,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

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

@@ -22,8 +22,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 +34,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

@@ -1,5 +1,6 @@
class AddTakenAtToReadouts < ActiveRecord::Migration[7.2] class AddTakenAtToReadouts < ActiveRecord::Migration[7.2]
def change def change
add_column :readouts, :taken_at, :datetime add_column :readouts, :taken_at, :datetime
add_index :readouts, [:user_id, :taken_at]
end end
end end

View File

@@ -39,6 +39,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_03_000000) do
t.index ["quantity_id"], name: "index_readouts_on_quantity_id" t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id"], name: "index_readouts_on_unit_id" t.index ["unit_id"], name: "index_readouts_on_unit_id"
t.index ["user_id"], name: "index_readouts_on_user_id" t.index ["user_id"], name: "index_readouts_on_user_id"
t.index ["user_id", "taken_at"], name: "index_readouts_on_user_id_and_taken_at"
end end
create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t| create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|

View File

@@ -21,3 +21,4 @@ end
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all } #[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
require_relative 'seeds/units.rb' require_relative 'seeds/units.rb'
require_relative 'seeds/demo.rb'

87
db/seeds/demo.rb Normal file
View File

@@ -0,0 +1,87 @@
demo_email = 'demo@localhost'
User.transaction do
break if User.find_by(email: demo_email)
demo = User.create! email: demo_email, password: 'demo123', status: :active do |user|
user.skip_confirmation!
print "Creating demo account '#{user.email}' with password '#{user.password}'..."
end
puts "done."
# --- Units ---
u = {}
u[:kg] = demo.units.create! symbol: 'kg', description: 'kilogram'
u[:g] = demo.units.create! symbol: 'g', description: 'gram', base: u[:kg], multiplier: 1e-3
u[:cm] = demo.units.create! symbol: 'cm', description: 'centimetre'
u[:bpm] = demo.units.create! symbol: 'bpm', description: 'beats per minute'
u[:pct] = demo.units.create! symbol: '%', description: 'percent'
u[:h] = demo.units.create! symbol: 'h', description: 'hour'
u[:min] = demo.units.create! symbol: 'min', description: 'minute', base: u[:h], multiplier: (1.0/60).round(10)
u[:kcal]= demo.units.create! symbol: 'kcal',description: 'kilocalorie'
u[:mg] = demo.units.create! symbol: 'mg', description: 'milligram', base: u[:kg], multiplier: 1e-6
u[:mmhg]= demo.units.create! symbol: 'mmHg',description: 'millimetre of mercury'
# --- Quantities ---
body = demo.quantities.create! name: 'Body'
weight = demo.quantities.create! name: 'Weight', parent: body
height = demo.quantities.create! name: 'Height', parent: body
fat = demo.quantities.create! name: 'Body fat', parent: body
cardio = demo.quantities.create! name: 'Cardiovascular'
hr_rest = demo.quantities.create! name: 'Resting HR', parent: cardio
hr_peak = demo.quantities.create! name: 'Peak HR', parent: cardio
bp_sys = demo.quantities.create! name: 'BP systolic', parent: cardio
bp_dia = demo.quantities.create! name: 'BP diastolic', parent: cardio
activity = demo.quantities.create! name: 'Activity'
sleep_dur = demo.quantities.create! name: 'Sleep', parent: activity
calories = demo.quantities.create! name: 'Calories out', parent: activity
nutrition = demo.quantities.create! name: 'Nutrition'
cal_in = demo.quantities.create! name: 'Calories in', parent: nutrition
caffeine = demo.quantities.create! name: 'Caffeine', parent: nutrition
# --- Readouts (60 days of daily-ish data) ---
base_time = 60.days.ago.beginning_of_day
rng = Random.new(42)
weight_val = 82.4
fat_val = 21.5
hr_rest_val = 62.0
60.times do |i|
t = base_time + i.days + rng.rand(3600 * 2)
weight_val += rng.rand(-0.3..0.3)
fat_val += rng.rand(-0.15..0.15)
hr_rest_val += rng.rand(-1.5..1.5)
hr_rest_val = hr_rest_val.clamp(52, 72)
demo.readouts.create! quantity: weight, unit: u[:kg], value: weight_val.round(1), created_at: t
demo.readouts.create! quantity: fat, unit: u[:pct], value: fat_val.round(1), created_at: t
demo.readouts.create! quantity: hr_rest, unit: u[:bpm], value: hr_rest_val.round, created_at: t
if i % 2 == 0
demo.readouts.create! quantity: bp_sys, unit: u[:mmhg], value: (115 + rng.rand(-8..8)).round, created_at: t
demo.readouts.create! quantity: bp_dia, unit: u[:mmhg], value: (75 + rng.rand(-5..5)).round, created_at: t
end
if i % 3 == 0
demo.readouts.create! quantity: hr_peak, unit: u[:bpm], value: (155 + rng.rand(-10..10)).round, created_at: t
end
demo.readouts.create! quantity: sleep_dur, unit: u[:h], value: (6.5 + rng.rand(-1.5..1.5)).round(1), created_at: t
demo.readouts.create! quantity: calories, unit: u[:kcal],value: (2100 + rng.rand(-300..300)).round, created_at: t
demo.readouts.create! quantity: cal_in, unit: u[:kcal],value: (1900 + rng.rand(-400..400)).round, created_at: t
if i % 4 == 0
demo.readouts.create! quantity: caffeine, unit: u[:mg], value: (200 + rng.rand(-80..80)).round, created_at: t
end
end
# height is stable — record once
demo.readouts.create! quantity: height, unit: u[:cm], value: 178.0, created_at: base_time
puts " Created #{demo.units.count} units, #{demo.quantities.count} quantities, #{demo.readouts.count} readouts."
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