// Configure your import map in config/importmap.rb. Read more: // https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" /* Hide page before loaded for testing purposes */ function showPage(event) { document.documentElement.style.visibility="visible" } 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 buildCharts() { var tbody = document.getElementById('measurements'); var container = document.getElementById('measurements-charts'); if (!tbody || !container) return; var rows = Array.from(tbody.querySelectorAll('tr[data-taken-at]')); container.innerHTML = ''; if (rows.length === 0) return; // Collect data per quantity, preserving insertion order var quantities = new Map(); rows.forEach(function(r) { var qid = r.dataset.quantityId; if (!qid) return; if (!quantities.has(qid)) { quantities.set(qid, { name: r.dataset.quantityName, unit: r.dataset.unit, x: [], y: [] }); } var q = quantities.get(qid); q.x.push(r.dataset.takenAt); q.y.push(parseFloat(r.dataset.value)); }); quantities.forEach(function(q) { // Sort by time ascending for the line var pairs = q.x.map(function(x, i) { return [x, q.y[i]]; }); pairs.sort(function(a, b) { return a[0] < b[0] ? -1 : 1; }); var div = document.createElement('div'); div.className = 'chart-panel'; container.appendChild(div); (function(el, name, unit, data) { Plotly.newPlot(el, [{ x: data.map(function(p) { return p[0]; }), y: data.map(function(p) { return p[1]; }), mode: 'lines+markers', type: 'scatter', name: name, line: { color: '#009ade' }, marker: { color: '#009ade', size: 6 } }], { title: { text: name, font: { size: 14 } }, height: 280, xaxis: { type: 'date', tickformat: '%Y-%m-%d' }, yaxis: { title: { text: unit } }, margin: { t: 40, r: 20, b: 60, l: 60 }, paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', font: { family: 'system-ui' } }, { responsive: true, displayModeBar: false }); }(div, q.name, q.unit, pairs)); }); } function getMeasurementsView() { return localStorage.getItem('measurements-view') || 'compact'; } function applyMeasurementsView(view) { document.body.dataset.measurementsView = view; if (view === 'wide') buildWideTable(); if (view === 'charts') buildCharts(); } 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(); var view = getMeasurementsView(); if (view === 'wide') buildWideTable(); if (view === 'charts') buildCharts(); }).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 var span = target.querySelector('summary > span') var button = target.querySelector('button') if (count > 0) { span.textContent = count + ' selected'; Turbo.StreamElement.prototype.enableElement(button) } else { span.textContent = span.getAttribute('data-prompt') Turbo.StreamElement.prototype.disableElement(button) } } window.detailsChange = detailsChange /* Close open
when focus lost */ function detailsClose(event) { if (!event.relatedTarget || event.relatedTarget.closest("details") != event.currentTarget) { event.currentTarget.removeAttribute("open") } } window.detailsClose = detailsClose 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; var input = document.getElementById(id) if (!input.checkValidity()) { input.reportValidity() event.preventDefault() } } window.formValidate = formValidate /* Turbo stream actions */ Turbo.StreamElement.prototype.disableElement = function(element) { element.setAttribute("disabled", "disabled") element.setAttribute("aria-disabled", "true") element.setAttribute("tabindex", "-1") } Turbo.StreamActions.disable = function() { this.targetElements.forEach((e) => { this.disableElement(e) }) } Turbo.StreamElement.prototype.enableElement = function(element) { element.removeAttribute("disabled") element.removeAttribute("aria-disabled") // Assume 'tabindex' is not used explicitly, so removing it is safe element.removeAttribute("tabindex") } Turbo.StreamActions.enable = function() { this.targetElements.forEach((e) => { this.enableElement(e) }) } /* TODO: change to visibility = collapse to avoid width change? */ Turbo.StreamActions.hide = function() { this.targetElements.forEach((e) => { e.style.display = "none" }) } Turbo.StreamActions.show = function() { this.targetElements.forEach((e) => { e.style.removeProperty("display") }) } /* Turbo.StreamActions.collapse = function() { this.targetElements.forEach((e) => { e.style.visibility = "collapse" }) } */ Turbo.StreamActions.close_form = function() { this.targetElements.forEach((e) => { /* Move focus if there's no focus or focus inside form being closed */ const focused = document.activeElement if (!focused || (focused == document.body) || e.contains(focused)) { let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`) nextForm ??= e.parentElement.querySelector("tr:has([autofocus])") nextForm?.querySelector("[autofocus]").focus() } document.getElementById(e.getAttribute("data-form")).remove() if (e.hasAttribute("data-link")) { this.enableElement(document.getElementById(e.getAttribute("data-link"))) } if (e.hasAttribute("data-hidden-row")) { document.getElementById(e.getAttribute("data-hidden-row")).removeAttribute("style") } e.remove() }) } Turbo.StreamActions.unselect = function() { this.targetElements.forEach((e) => { e.checked = false this.enableElement(e) }) } function formProcessKey(event) { switch (event.key) { case "Escape": event.currentTarget.querySelector("a[name=cancel]").click() break case "Enter": event.currentTarget.querySelector("button[name=button]").click() event.preventDefault() break } } window.formProcessKey = formProcessKey function detailsProcessKey(event) { // TODO: up/down arrows to move focus to prev/next line switch (event.key) { case "Escape": if (event.currentTarget.hasAttribute("open")) { event.currentTarget.removeAttribute("open") event.stopPropagation() } break case "Enter": var button = event.currentTarget.querySelector("button:not([disabled])") if (button) { button.click() // Autofocus won't be respected unless target is blurred event.target.blur() event.preventDefault() event.stopPropagation() } break } } window.detailsProcessKey = detailsProcessKey; /* Items table drag and drop support */ var lastEnterTime function dragStart(event) { lastEnterTime = event.timeStamp var row = event.currentTarget row.closest("table").querySelectorAll("thead tr").forEach((tr) => { tr.toggleAttribute("hidden") }) event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path")) var rowRectangle = row.getBoundingClientRect() event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top) event.dataTransfer.dropEffect = "move" } window.dragStart = dragStart /* * Drag tracking assumptions (based on FF 122.0 experience): * * Enter/Leave events at the same timeStamp may not be logically ordered * (e.g. E -> E -> L, not E -> L -> E), * * not every Enter event has corresponding Leave event, especially during * rapid pointer moves * NOTE: sometimes Leave is not emitted when pointer goes fast over table * and outside. This should probably be fixed in browser, than patched here. */ function dragEnter(event) { //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id) dragLeave(event) lastEnterTime = event.timeStamp const id = event.currentTarget.getAttribute("data-drop-id") document.getElementById(id).classList.add("dropzone") } window.dragEnter = dragEnter function dragOver(event) { event.preventDefault() } window.dragOver = dragOver function dragLeave(event) { //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id) // Leave has been accounted for by Enter at the same timestamp, processed earlier if (event.timeStamp <= lastEnterTime) return event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => { tr.classList.remove("dropzone") }) } window.dragLeave = dragLeave function dragEnd(event) { dragLeave(event) event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => { tr.toggleAttribute("hidden") }) } window.dragEnd = dragEnd function drop(event) { event.preventDefault() var params = new URLSearchParams() var id_param = event.currentTarget.getAttribute("data-drop-id-param") var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop() params.append(id_param, id) fetch(event.dataTransfer.getData("text/plain"), { 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: "POST" }) .then(response => response.text()) .then(html => Turbo.renderStreamMessage(html)) } window.drop = drop