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
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
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
5051122bcd 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>
2026-04-04 09:55:31 +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
71c22f2280 Add Plotly line charts view to Measurements page
Users can now switch to a Charts view that renders a separate
time-series line chart for each tracked quantity, using Plotly.js
loaded via CDN. Charts are sorted chronologically and styled to
match the app palette. A dedicated toggle button and matching
CSS visibility rules mirror the existing Compact/Wide view pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:36:24 +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
17 changed files with 288 additions and 2 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

@@ -111,6 +111,11 @@ 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;
} }
@@ -666,6 +671,9 @@ 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

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

View File

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

View File

@@ -23,6 +23,54 @@ 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() { function getMeasurementsView() {
return localStorage.getItem('measurements-view') || 'compact'; return localStorage.getItem('measurements-view') || 'compact';
} }
@@ -38,6 +86,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();
@@ -82,9 +134,13 @@ window.detailsObserver = new MutationObserver((mutations) => {
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) {
Turbo.StreamElement.prototype.enableElement(button); button.removeAttribute('disabled');
button.removeAttribute('aria-disabled');
button.removeAttribute('tabindex');
} else { } else {
Turbo.StreamElement.prototype.disableElement(button); button.setAttribute('disabled', 'disabled');
button.setAttribute('aria-disabled', 'true');
button.setAttribute('tabindex', '-1');
} }
} }
window.readoutUnitChanged = readoutUnitChanged window.readoutUnitChanged = readoutUnitChanged

View File

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

View File

@@ -14,6 +14,7 @@
<%= 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

@@ -11,6 +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')" %>
</div> </div>
<div class="main-area measurements-section"> <div class="main-area measurements-section">

View File

@@ -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.
@@ -99,6 +101,7 @@ 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,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}

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

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

View 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