diff --git a/app/assets/images/pictograms/view-columns.svg b/app/assets/images/pictograms/view-columns.svg
new file mode 100644
index 0000000..889e3c1
--- /dev/null
+++ b/app/assets/images/pictograms/view-columns.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/pictograms/view-rows.svg b/app/assets/images/pictograms/view-rows.svg
new file mode 100644
index 0000000..51e7655
--- /dev/null
+++ b/app/assets/images/pictograms/view-rows.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index d01d199..01cbe75 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -36,6 +36,7 @@
--color-blue: #009ade;
--color-dark-red: #b21237;
--color-red: #ff1f5b;
+ --color-purple: #8b2be2;
--depth: 0;
@@ -231,6 +232,10 @@ textarea:invalid {
text-decoration: underline 1px var(--color-border-gray);
text-underline-offset: 0.25em;
}
+button.link {
+ border: none;
+ padding: 0;
+}
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
@@ -252,6 +257,13 @@ textarea:invalid {
background-color: var(--color-red);
border-color: var(--color-red);
}
+tr:has(select[data-changed]) button[name="button"],
+.set-default-unit:not([disabled]) {
+ background-color: var(--color-purple);
+ border-color: var(--color-purple);
+ color: white;
+ fill: white;
+}
.link:focus-visible {
text-decoration-color: var(--color-gray);
}
@@ -361,7 +373,7 @@ header {
pointer-events: auto;
}
.flash:before {
- filter: invert();
+ filter: invert(100%);
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
@@ -513,7 +525,7 @@ header {
cursor: grab;
}
.items-table .form td {
- vertical-align: top;
+ vertical-align: middle;
}
.items-table td:not(:first-child),
.grayed {
@@ -640,3 +652,42 @@ li::marker {
min-width: 66%;
width: max-content;
}
+.measurements-section {
+ overflow-x: auto;
+}
+body[data-measurements-view=wide] .measurements-compact,
+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] {
+ background-color: var(--color-blue);
+ border-color: var(--color-blue);
+ color: white;
+ fill: white;
+}
+#measurements tr.grouped td {
+ border-top: none;
+}
+#measurements tr.grouped .taken-at,
+#measurements tr.grouped .created-at {
+ visibility: hidden;
+}
+.measurements-wide td {
+ vertical-align: middle;
+ white-space: nowrap;
+}
+.wide-cell {
+ align-items: center;
+ display: inline-flex;
+ gap: 0.25em;
+}
+.wide-cell .button {
+ border: none;
+ font-size: inherit;
+ height: auto;
+ padding: 0;
+}
+.wide-cell button.link::after {
+ content: none;
+}
diff --git a/app/controllers/measurements_controller.rb b/app/controllers/measurements_controller.rb
index ebe6622..b824cf1 100644
--- a/app/controllers/measurements_controller.rb
+++ b/app/controllers/measurements_controller.rb
@@ -1,7 +1,12 @@
class MeasurementsController < ApplicationController
+ before_action :find_readout, only: [:destroy, :edit, :update]
+
+ before_action except: :index do
+ raise AccessForbidden unless current_user.at_least(:active)
+ end
+
def index
- @measurements = []
- #@measurements = current_user.units.ordered.includes(:base, :subunits)
+ @measurements = current_user.readouts.includes(:quantity, :unit).order(taken_at: :desc, id: :desc)
end
def new
@@ -9,8 +14,40 @@ class MeasurementsController < ApplicationController
end
def create
+ taken_at = params.permit(:taken_at)[:taken_at]
+ readout_params = params.permit(readouts: Readout::ATTRIBUTES).fetch(:readouts, [])
+ @readouts = readout_params.map { |rp| current_user.readouts.build(rp.merge(taken_at: taken_at)) }
+
+ if @readouts.present? && @readouts.all?(&:valid?)
+ ActiveRecord::Base.transaction { @readouts.each(&:save!) }
+ flash.now[:notice] = t('.success', count: @readouts.size)
+ else
+ errors = @readouts.flat_map { |r| r.errors.full_messages }
+ flash.now[:alert] = errors.present? ? errors.first : t('.no_readouts')
+ end
+ end
+
+ def edit
+ @user_units = current_user.units.ordered
+ end
+
+ def update
+ if @readout.update(params.require(:readout).permit(:value, :unit_id, :taken_at))
+ flash.now[:notice] = t('.success')
+ else
+ @user_units = current_user.units.ordered
+ render :edit
+ end
end
def destroy
+ @readout.destroy!
+ flash.now[:notice] = t('.success')
+ end
+
+ private
+
+ def find_readout
+ @readout = current_user.readouts.find(params[:id])
end
end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 09ced32..4eb29d3 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -9,6 +9,143 @@ function showPage(event) {
}
document.addEventListener('turbo:load', showPage)
+function groupMeasurements() {
+ var tbody = document.getElementById('measurements');
+ if (!tbody) return;
+ var prevTakenAt = null;
+ Array.from(tbody.querySelectorAll('tr[data-taken-at]'))
+ .filter(function(row) { return row.style.display !== 'none' })
+ .forEach(function(row) {
+ var takenAt = row.dataset.takenAt;
+ row.classList.toggle('grouped', takenAt !== null && takenAt === prevTakenAt);
+ prevTakenAt = takenAt;
+ });
+}
+
+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 getMeasurementsView() {
+ return localStorage.getItem('measurements-view') || 'compact';
+}
+
+function applyMeasurementsView(view) {
+ document.body.dataset.measurementsView = view;
+ if (view === 'wide') buildWideTable();
+}
+
+function setMeasurementsView(view) {
+ localStorage.setItem('measurements-view', view);
+ applyMeasurementsView(view);
+}
+window.setMeasurementsView = setMeasurementsView
+
+document.addEventListener('turbo:load', function() {
+ var tbody = document.getElementById('measurements');
+ if (!tbody) return;
+ groupMeasurements();
+ applyMeasurementsView(getMeasurementsView());
+ new MutationObserver(function() {
+ groupMeasurements();
+ if (getMeasurementsView() === 'wide') buildWideTable();
+ }).observe(tbody, {
+ childList: true, subtree: true,
+ attributes: true, attributeFilter: ['style']
+ });
+})
+
function detailsChange(event) {
var target = event.currentTarget
var count = target.querySelectorAll('input:checked:not([disabled])').length
@@ -37,6 +174,65 @@ window.detailsObserver = new MutationObserver((mutations) => {
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) {
+ 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');
+ } else {
+ button.setAttribute('disabled', 'disabled');
+ button.setAttribute('aria-disabled', 'true');
+ button.setAttribute('tabindex', '-1');
+ }
+}
+window.readoutUnitChanged = readoutUnitChanged
+
+function setDefaultUnit(button) {
+ var select = button.closest('tr').querySelector('select[data-default-unit-id]');
+ var params = new URLSearchParams();
+ params.append('quantity[default_unit_id]', select.value);
+
+ fetch(button.dataset.path, {
+ body: params,
+ headers: {
+ 'Accept': 'text/vnd.turbo-stream.html',
+ 'X-CSRF-Token': document.head.querySelector('meta[name=csrf-token]').content,
+ 'X-Requested-With': 'XMLHttpRequest'
+ },
+ method: 'PATCH'
+ })
+ .then(response => {
+ if (response.ok) {
+ select.dataset.defaultUnitId = select.value;
+ readoutUnitChanged(select);
+ }
+ return response.text();
+ })
+ .then(html => Turbo.renderStreamMessage(html));
+}
+window.setDefaultUnit = setDefaultUnit
+
function formValidate(event) {
var id = event.submitter.getAttribute("data-validate")
if (!id) return;
diff --git a/app/views/measurements/_edit_form.html.erb b/app/views/measurements/_edit_form.html.erb
new file mode 100644
index 0000000..845ef41
--- /dev/null
+++ b/app/views/measurements/_edit_form.html.erb
@@ -0,0 +1,24 @@
+<%= tabular_fields_for @readout, form: form_tag do |form| %>
+ <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
+ data: {form: form_tag, hidden_row: hidden_row, link: link} do %>
+
<%= @readout.quantity %> |
+
+ <%= form.number_field :value, required: true, autofocus: true %>
+ |
+
+ <%= form.collection_select :unit_id, @user_units, :id,
+ ->(u){ sanitize(' ' * (u.base_id? ? 1 : 0) + u.symbol) },
+ {}, required: true %>
+ |
+
+ <%= form.datetime_field :taken_at %>
+ |
+ |
+
+ <%= form.button %>
+ <%= image_link_to t(:cancel), "close-outline", measurements_path,
+ class: 'dangerous', name: :cancel,
+ onclick: render_turbo_stream('edit_form_close', {row: row}) %>
+ |
+ <% end %>
+<% end %>
diff --git a/app/views/measurements/_edit_form_close.html.erb b/app/views/measurements/_edit_form_close.html.erb
new file mode 100644
index 0000000..e79ea3c
--- /dev/null
+++ b/app/views/measurements/_edit_form_close.html.erb
@@ -0,0 +1,2 @@
+<%= turbo_stream.close_form row %>
+<%= turbo_stream.update :flashes %>
diff --git a/app/views/measurements/_edit_panel.html.erb b/app/views/measurements/_edit_panel.html.erb
new file mode 100644
index 0000000..20f59b2
--- /dev/null
+++ b/app/views/measurements/_edit_panel.html.erb
@@ -0,0 +1,33 @@
+<% form_tag = dom_id(@readout, :edit, :form) %>
+<% row = dom_id(@readout, :edit) %>
+<% hidden_row = dom_id(@readout) %>
+
+<%= tabular_form_with model: @readout, url: measurement_path(@readout),
+ id: form_tag do |form| %>
+
+
+ <%= tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
+ data: {form: form_tag, hidden_row: hidden_row} do %>
+ | <%= @readout.quantity %> |
+
+ <%= form.number_field :value, required: true, autofocus: true %>
+ |
+
+ <%= form.collection_select :unit_id, @user_units, :id,
+ ->(u){ sanitize(' ' * (u.base_id? ? 1 : 0) + u.symbol) },
+ {}, required: true %>
+ |
+
+ <%= form.datetime_field :taken_at %>
+ |
+ |
+
+ <%= form.button %>
+ <%= image_link_to t(:cancel), "close-outline", measurements_path,
+ class: 'dangerous', name: :cancel,
+ onclick: render_turbo_stream('edit_form_close', {row: row}) %>
+ |
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/measurements/_readout.html.erb b/app/views/measurements/_readout.html.erb
new file mode 100644
index 0000000..ab1c864
--- /dev/null
+++ b/app/views/measurements/_readout.html.erb
@@ -0,0 +1,22 @@
+<%= tag.tr id: dom_id(readout), data: {taken_at: readout.taken_at&.iso8601,
+ quantity_id: readout.quantity_id, quantity_name: readout.quantity.name,
+ value: format("%.10g", readout.value), unit: readout.unit.symbol} do %>
+
+ <% if current_user.at_least(:active) %>
+ <%= link_to readout.quantity, edit_measurement_path(readout),
+ class: 'link', onclick: 'this.blur();', data: {turbo_stream: true} %>
+ <% else %>
+ <%= readout.quantity %>
+ <% end %>
+ |
+ <%= format("%.10g", readout.value) %> |
+ <%= readout.unit %> |
+ <%= l(readout.taken_at) if readout.taken_at %> |
+ <%= l(readout.created_at) %> |
+ <% if current_user.at_least(:active) %>
+
+ <%= image_button_to t('.destroy'), 'delete-outline', measurement_path(readout),
+ method: :delete, data: {turbo_stream: true} %>
+ |
+ <% end %>
+<% end %>
diff --git a/app/views/measurements/edit.turbo_stream.erb b/app/views/measurements/edit.turbo_stream.erb
new file mode 100644
index 0000000..18424a1
--- /dev/null
+++ b/app/views/measurements/edit.turbo_stream.erb
@@ -0,0 +1,18 @@
+<% ids = {row: dom_id(@readout, :edit),
+ hidden_row: dom_id(@readout),
+ link: nil,
+ form_tag: dom_id(@readout, :edit, :form)} %>
+
+<% if params[:view] == 'wide' %>
+ <%= turbo_stream.update :measurement_edit_form, partial: 'edit_panel' %>
+ <%= turbo_stream.hide ids[:hidden_row] %>
+<% else %>
+ <%= turbo_stream.append :measurement_edit_form do %>
+ <%- tabular_form_with model: @readout, url: measurement_path(@readout),
+ html: {id: ids[:form_tag]} do %>
+ <% end %>
+ <% end %>
+ <%= turbo_stream.hide ids[:hidden_row] %>
+ <%= turbo_stream.remove ids[:row] %>
+ <%= turbo_stream.after @readout, partial: 'edit_form', locals: ids -%>
+<% end %>
diff --git a/app/views/measurements/index.html.erb b/app/views/measurements/index.html.erb
index bac10e1..2aac86b 100644
--- a/app/views/measurements/index.html.erb
+++ b/app/views/measurements/index.html.erb
@@ -5,10 +5,34 @@
id: :new_measurement_link, onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %>
+ <%= image_button_tag '', 'view-rows', name: nil, type: 'button',
+ class: 'view-toggle', title: t('.view_compact'),
+ data: {view: 'compact'}, onclick: "setMeasurementsView('compact')" %>
+ <%= image_button_tag '', 'view-columns', name: nil, type: 'button',
+ class: 'view-toggle', title: t('.view_wide'),
+ data: {view: 'wide'}, onclick: "setMeasurementsView('wide')" %>
+
+
+
+ <%= tag.div id: :measurement_edit_form %>
+
+
+
+ | <%= Quantity.model_name.human %> |
+ <%= Readout.human_attribute_name(:value) %> |
+ <%= Unit.model_name.human %> |
+ <%= Readout.human_attribute_name(:taken_at) %> |
+ <%= Readout.human_attribute_name(:created_at) %> |
+ <% if current_user.at_least(:active) %>
+ |
+ <% end %>
+
+
+
+ <%= render(partial: 'readout', collection: @measurements, as: :readout) || render_no_items %>
+
+
+
+
-
-
- <%= render(@measurements) || render_no_items %>
-
-
diff --git a/app/views/measurements/update.turbo_stream.erb b/app/views/measurements/update.turbo_stream.erb
new file mode 100644
index 0000000..36f4d44
--- /dev/null
+++ b/app/views/measurements/update.turbo_stream.erb
@@ -0,0 +1,2 @@
+<%= turbo_stream.close_form dom_id(@readout, :edit) %>
+<%= turbo_stream.replace @readout, partial: 'measurements/readout', locals: {readout: @readout} %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b8fd2db..5f70cc5 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -97,13 +97,18 @@ en:
taken_at_html: Measurement taken at
index:
new_measurement: Add measurement
+ view_compact: Compact view
+ view_wide: Wide view
readout:
+ edit: Edit
destroy: Delete
create:
success:
one: Recorded 1 measurement.
other: Recorded %{count} measurements.
no_readouts: No readouts selected.
+ update:
+ success: Measurement updated.
destroy:
success: Measurement deleted.
quantities:
diff --git a/test/controllers/measurements_controller_test.rb b/test/controllers/measurements_controller_test.rb
index 025e77e..0adaa76 100644
--- a/test/controllers/measurements_controller_test.rb
+++ b/test/controllers/measurements_controller_test.rb
@@ -1,8 +1,7 @@
require "test_helper"
class MeasurementsControllerTest < ActionDispatch::IntegrationTest
- #test "should get index" do
- # get measurements_index_url
- # assert_response :success
- #end
+ # test "the truth" do
+ # assert true
+ # end
end
diff --git a/test/system/measurements_test.rb b/test/system/measurements_test.rb
new file mode 100644
index 0000000..a82f57f
--- /dev/null
+++ b/test/system/measurements_test.rb
@@ -0,0 +1,64 @@
+require "application_system_test_case"
+
+class MeasurementsTest < ApplicationSystemTestCase
+ setup do
+ @user = sign_in(user: users(:alice))
+
+ @quantity = @user.quantities.create!(name: 'Weight')
+ @unit = @user.units.create!(symbol: 'kg')
+ @readout = @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5)
+
+ visit measurements_path
+ end
+
+ test "index shows quantity name as edit link for active user" do
+ within 'tbody' do
+ assert_selector :link, exact_text: @quantity.name
+ end
+ end
+
+ test "edit opens inline form on quantity link click" do
+ within 'tbody' do
+ click_on @quantity.name
+ assert_selector ':focus'
+ assert_selector 'input[name="readout[value]"]'
+ end
+ end
+
+ test "edit and update measurement value" do
+ within 'tbody' do
+ click_on @quantity.name
+ fill_in 'readout[value]', with: '83.1'
+ assert_difference ->{ @readout.reload.value }, 83.1 - @readout.value do
+ click_on t('helpers.submit.update')
+ end
+ assert_no_selector :fillable_field
+ assert_selector :link, exact_text: @quantity.name
+ end
+ assert_selector '.flash.notice', text: t('measurements.update.success')
+ end
+
+ test "cancel edit restores original row" do
+ within 'tbody' do
+ click_on @quantity.name
+ assert_selector 'input[name="readout[value]"]'
+ click_on t(:cancel)
+ assert_no_selector :fillable_field
+ assert_selector :link, exact_text: @quantity.name
+ end
+ end
+
+ test "wide view edit opens panel form" do
+ @readout.update!(taken_at: Time.now)
+ visit measurements_path
+ execute_script("localStorage.removeItem('measurements-view')")
+ visit measurements_path
+
+ find('button[data-view="wide"]').click
+ within '#measurements-wide' do
+ assert_text format("%.10g", 82.5), wait: 3
+ find('button.link').click
+ end
+ assert_selector '#measurement_edit_form input[name="readout[value]"]', wait: 5
+ end
+end