Compare commits

..

1 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
17 changed files with 2 additions and 288 deletions

View File

@@ -77,28 +77,6 @@ default/ namespace for default units import/export and admin panel
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
The database must support:

View File

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

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

View File

@@ -23,54 +23,6 @@ function groupMeasurements() {
}
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() {
return localStorage.getItem('measurements-view') || 'compact';
}
@@ -86,10 +38,6 @@ 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();
@@ -134,13 +82,9 @@ window.detailsObserver = new MutationObserver((mutations) => {
function readoutUnitChanged(select) {
var button = select.closest('tr').querySelector('.set-default-unit');
if (select.value && select.value !== select.dataset.defaultUnitId) {
button.removeAttribute('disabled');
button.removeAttribute('aria-disabled');
button.removeAttribute('tabindex');
Turbo.StreamElement.prototype.enableElement(button);
} else {
button.setAttribute('disabled', 'disabled');
button.setAttribute('aria-disabled', 'true');
button.setAttribute('tabindex', '-1');
Turbo.StreamElement.prototype.disableElement(button);
}
}
window.readoutUnitChanged = readoutUnitChanged

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 %>
<%= stylesheet_link_tag "spreadsheet" %>
<script src="https://cdn.plot.ly/plotly-basic-2.35.2.min.js"></script>
<%= javascript_importmap_tags %>
<%#= turbo_page_requires_reload_tag %>

View File

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

View File

@@ -89,8 +89,6 @@ en:
readouts:
form:
set_default_unit: Set as default unit
charts:
navigation: Charts
measurements:
navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
@@ -101,7 +99,6 @@ en:
new_measurement: Add measurement
view_compact: Compact view
view_wide: Wide view
view_charts: Charts
readout:
edit: Edit
destroy: Delete

View File

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

View File

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

View File

@@ -39,7 +39,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_03_000000) do
t.index ["quantity_id"], name: "index_readouts_on_quantity_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", "taken_at"], name: "index_readouts_on_user_id_and_taken_at"
end
create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|

View File

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

View File

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