Merge feature/measurements-wide-view into demo/example-data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:30:13 +00:00
7 changed files with 61 additions and 119 deletions

View File

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

View File

@@ -22,98 +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);
});
var takenAtHeader = (document.querySelector('[data-column="taken-at"]') || {}).textContent || '';
var createdAtHeader = (document.querySelector('[data-column="created-at"]') || {}).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');
@@ -169,7 +77,6 @@ function getMeasurementsView() {
function applyMeasurementsView(view) {
document.body.dataset.measurementsView = view;
if (view === 'wide') buildWideTable();
}
function setMeasurementsView(view) {
@@ -189,8 +96,6 @@ document.addEventListener('turbo:load', function() {
applyMeasurementsView(getMeasurementsView());
new MutationObserver(function() {
groupMeasurements();
var view = getMeasurementsView();
if (view === 'wide') buildWideTable();
}).observe(tbody, {
childList: true, subtree: true,
attributes: true, attributeFilter: ['style']
@@ -225,26 +130,6 @@ 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'});
}
});
})
.catch(err => console.error('editMeasurementWide failed:', err));
}
window.editMeasurementWide = editMeasurementWide
function readoutUnitChanged(select) {
var button = select.closest('tr').querySelector('.set-default-unit');

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

@@ -1,4 +1,4 @@
<% if @readouts.all?(&:persisted?) %>
<% if @readouts.present? && @readouts.all?(&:persisted?) %>
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.enable :new_measurement_link %>
@@ -6,6 +6,8 @@
<% @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

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

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

View File

@@ -1,2 +1,4 @@
<%= turbo_stream.close_form dom_id(@readout, :edit) %>
<%= 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} %>