Compare commits

..

25 Commits

Author SHA1 Message Date
eb8fe7622a Fix autofocus on dynamically inserted forms, remove this.blur() handlers
form_controller.connect() now blurs the previously focused element and
explicitly focuses the [autofocus] element when a form is inserted into
the DOM (via Turbo Stream). Only runs when an [autofocus] element is
present, so closing forms and other stream updates are unaffected.

Remove all onclick='this.blur()' inline handlers from templates — they
were a workaround for the same autofocus problem, now solved properly
via the Stimulus lifecycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:16:34 +00:00
fee3ce8627 Migrate all inline JS to Stimulus controllers
Add stimulus-rails gem and wire up 7 controllers:
- measurements_view_controller: view toggle (compact/wide) via localStorage
- measurements_controller: grouped rows MutationObserver
- charts_controller: Plotly chart rendering
- form_controller: keyboard shortcuts (Escape/Enter) and submit validation
- details_controller: quantity picker state, focusout close, MutationObserver
- readout_unit_controller: default unit button enable/disable + PATCH submission
- drag_controller: drag-and-drop for quantity reparenting and unit rebasing

Remove all inline onclick/onkeydown/ondrag*/onsubmit handlers from templates.
Remove all window.* global exports from application.js.
Remove bare <script> block from measurements/_form.html.erb.
Remove turbo:load listeners for behavior now in controller connect().

application.js now only contains: Turbo Stream custom action definitions
and the showPage visibility listener.

Document Stimulus conventions in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:44:44 +00:00
74341b6b38 Merge branch 'feature/measurements-wide-view' into demo/example-data 2026-04-04 13:08:33 +00:00
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
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
366662a948 Replace JS-generated wide table with ERB partial and Turbo Streams
- Add _wide_table.html.erb partial (server-rendered pivot table)
- Add load_measurements helper in controller to prepare @wide_groups and
  @wide_quantities for all mutating actions
- Update index view to render the wide_table partial in #measurements-wide
- Add/update create, destroy, update turbo_stream views to refresh the
  wide table atomically after each mutation
- Remove buildWideTable() and editMeasurementWide() from application.js
- Fix create.turbo_stream.erb condition (empty readouts are vacuously all persisted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:28:32 +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
1bc75f5d40 Fix column header lookup fragility, add fetch error handling, add tests
- Replace position-based column header lookup (ths[3]/ths[4]) with
  data-column attribute selectors — immune to column reordering
- Add .catch() error handlers to editMeasurementWide and setDefaultUnit
  fetch calls so failures surface in the console instead of silently
  disappearing
- Add MeasurementsController integration tests covering index auth,
  create with taken_at, empty-readout create, destroy, cross-user
  destroy isolation, and update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:24:26 +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
bfd427c9b2 Add wide view and inline editing to Measurements page
The Measurements page gains a compact/wide view toggle (persisted in
localStorage). The wide view is a pivot table: rows = time points,
columns = quantity names (alphabetical), cells = value + delete button.

Clicking a value in either view opens an inline edit panel (Turbo Stream)
without leaving the page. The panel shows the quantity name, value input,
unit selector, taken_at picker, and Update/Cancel buttons.

Changes:
- MeasurementsController: add edit/update actions; order by taken_at desc
- measurements/index: compact table + wide container, view-toggle buttons
- measurements/_readout: data-* attributes for JS pivot builder; edit link
- measurements/_edit_panel, _edit_form, _edit_form_close,
  edit.turbo_stream, update.turbo_stream: inline edit views
- application.js: groupMeasurements, buildWideTable (alphabetical cols),
  getMeasurementsView / setMeasurementsView, editMeasurementWide,
  readoutUnitChanged, setDefaultUnit
- application.css: compact/wide visibility rules, .wide-cell flex layout,
  button.link reset, .items-table .form td alignment
- Pictograms: view-rows.svg, view-columns.svg (view-toggle icons)
- Locale: view_compact/view_wide toggle labels, edit link, update.success
- Tests: system tests for compact inline edit and wide view edit panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:03:10 +00:00
3702e24153 Add taken_at to readouts and default unit to quantities
Readouts gain a taken_at timestamp (distinct from created_at) that records
when the measurement was actually taken. Measurements are now ordered by
taken_at descending.

Quantities gain an optional default_unit association. When set, the unit
is pre-selected in the measurement form. A "Set as default" button on the
unit selector lets users update the default directly from the form.

- Migrations: add taken_at (datetime) to readouts,
              add default_unit_id (fk → units) to quantities
- Readout: expose taken_at in ATTRIBUTES permit-list
- Quantity: add default_unit belongs_to, expose in ATTRIBUTES
- QuantitiesController: load @user_units for form actions
- Quantities views: add Default unit column and select to form
- Readouts form: pre-select default unit; add "Set as default" button
  (readoutUnitChanged / setDefaultUnit wired up in a later commit)
- Measurements form: default taken_at input to current time
- ApplicationHelper: propagate :form option to html_options in builder
- config/environments/test.rb: allow Capybara's dynamic host
- Tests: system tests for default-unit UI on the Quantities page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:01:52 +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
64 changed files with 1210 additions and 367 deletions

122
CLAUDE.md Normal file
View File

@@ -0,0 +1,122 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Fixin.me is a "quantified self" Rails 7.2.3 application for personal data tracking. Users define hierarchical **quantities** (metrics to track), **units** (with optional conversion hierarchies), and **readouts** (individual measurements). There is also a non-persistent **measurement** model used as a form wrapper.
## Setup
Configuration files are distributed as `.dist` templates — copy and customize before use:
```bash
cp config/application.rb.dist config/application.rb
cp config/database.yml.dist config/database.yml
cp config/puma.rb.dist config/puma.rb
```
```bash
bundle config --local frozen true
bundle config --local path .gem
bundle config --local with mysql development test # or: pg, sqlite
bundle install
RAILS_ENV=development bundle exec rails db:create db:migrate db:seed
```
## Common Commands
```bash
bundle exec rails s # start server
bundle exec rails test # all unit/model/controller tests
bundle exec rails test:system # all system tests (Capybara + Selenium)
bundle exec rails test test/system/units_test.rb # single test file
bundle exec rails test --seed 64690 --name test_add_unit # single test by name
bundle exec rails db:seed:export # export default settings as seed file
```
## Architecture
### Data Model
- **Quantity** — hierarchical tree (self-referential `parent_id`). Cached `depth` and `pathname` fields are recomputed via recursive CTEs on write. Direct assignment to cached fields is blocked.
- **Unit** — optional hierarchy via `base_id` and `multiplier` for unit conversion. Multiplier precision/scale is validated by a custom validator.
- **Readout** — single measurement: `value` (IEEE 754 float), `quantity`, `unit`, `category`.
- **Measurement** — `ActiveModel::Model` form wrapper (not database-backed); bridges the readout creation form.
- **User** — Devise-managed with a status enum: `admin`, `active`, `restricted`, `locked`, `disabled`. Admins can disguise as other users.
### Hierarchical Queries
Both `Quantity` and `Unit` use recursive CTEs for tree traversal (ordered traversal, ancestors, progenies, common ancestors). `lib/core_ext/arel/` patches Arel to support CTE with `UPDATE`/`DELETE` statements, working around Rails issue #54658.
### Custom Extensions (`lib/core_ext/`)
- **arel/** — CTE support for UPDATE/DELETE
- **active_model/** — precision/scale validator used by `Unit#multiplier`
- **active_record/** — `attr_cached` mechanism (see `ApplicationRecord`)
- **action_view/** — record identifier suffixes
- Miscellaneous: `Array#delete_bang`, `BigDecimal` scientific notation
### Response Handling
Controllers respond to both HTML and Turbo Stream formats. Errors during Turbo Stream requests trigger a redirect with flash rather than rendering inline, handled in `ApplicationController`.
### Numeric Precision
Readout values are stored as IEEE 754 double-precision floats (not fixed-point decimals). Rationale in `DESIGN.md`: biological values span many orders of magnitude; 15-digit float precision is sufficient and avoids conversion overhead.
### Routes
```
measurements GET/POST /measurements
readouts GET/POST /readouts, DELETE /readouts/:id/discard
quantities CRUD + POST /quantities/:id/reparent
units CRUD + POST /units/:id/rebase
users CRUD + POST /users/:id/disguise, POST /users/revert
default/ namespace for default units import/export and admin panel
root → /units (authenticated), /sign_in (unauthenticated)
```
## JavaScript Conventions
### Use Stimulus for all JS behavior
This app uses **Hotwire = Turbo + Stimulus**. All JavaScript behavior must be in Stimulus controllers under `app/javascript/controllers/`. Never use:
- Inline HTML event handlers: `onclick="..."`, `onkeydown="..."`, `ondragstart="..."` etc.
- Global `window.*` function exports
- Bare `<script>` blocks in templates
- `turbo:load` listeners for behavior that belongs in a controller's `connect()` lifecycle
**Instead:**
- Put behavior in a Stimulus controller method
- Wire it with `data-action="event->controller#method"` in the template
- Use `data-controller="name"` on the root element, `data-[name]-target="targetName"` for targets, `data-[name]-[valueName]-value="..."` for values
- Use `connect()` / `disconnect()` for setup/teardown (MutationObservers, event listeners, etc.)
Controller filename `foo_bar_controller.js` → identifier `foo-bar``data-controller="foo-bar"`.
### 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:
- Recursive CTEs with `UPDATE`/`DELETE` (MySQL ≥ 8.0, PostgreSQL, or SQLite3)
- Decimal precision of 30+ digits

View File

@@ -25,6 +25,7 @@ gem "devise"
gem "importmap-rails"
gem "turbo-rails", "~> 2.0"
gem "stimulus-rails"
group :development, :test do
gem "byebug"

View File

@@ -273,6 +273,8 @@ GEM
sqlite3 (2.9.0-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
tilt (2.7.0)
@@ -324,6 +326,7 @@ DEPENDENCIES
selenium-webdriver
sprockets-rails
sqlite3 (~> 2.7)
stimulus-rails
turbo-rails (~> 2.0)
tzinfo-data
web-console

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M4,4H8V20H4V4M10,4H14V20H10V4M16,4H21V20H16V4Z"/></svg>

After

Width:  |  Height:  |  Size: 135 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M3,5H21V7H3V5M3,11H21V13H3V11M3,17H21V19H3V17Z"/></svg>

After

Width:  |  Height:  |  Size: 135 B

View File

@@ -36,6 +36,7 @@
--color-blue: #009ade;
--color-dark-red: #b21237;
--color-red: #ff1f5b;
--color-purple: #8b2be2;
--depth: 0;
@@ -110,6 +111,11 @@ svg {
svg:last-child {
margin-right: 0;
}
.chart-panel svg {
height: auto;
margin: 0;
width: auto;
}
textarea {
margin: 0;
}
@@ -231,6 +237,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 +262,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 +378,7 @@ header {
pointer-events: auto;
}
.flash:before {
filter: invert();
filter: invert(100%);
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
@@ -513,7 +530,7 @@ header {
cursor: grab;
}
.items-table .form td {
vertical-align: top;
vertical-align: middle;
}
.items-table td:not(:first-child),
.grayed {
@@ -640,3 +657,45 @@ 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;
}
.chart-panel {
width: 100%;
}
#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;
}

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

@@ -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)
load_measurements
end
def new
@@ -9,8 +14,49 @@ 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!) }
load_measurements
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))
load_measurements
flash.now[:notice] = t('.success')
else
@user_units = current_user.units.ordered
render :edit
end
end
def destroy
@readout.destroy!
load_measurements
flash.now[:notice] = t('.success')
end
private
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

@@ -8,6 +8,10 @@ class QuantitiesController < ApplicationController
raise AccessForbidden unless current_user.at_least(:active)
end
before_action only: [:new, :edit, :create, :update] do
@user_units = current_user.units.ordered
end
def index
@quantities = current_user.quantities.ordered.includes(:parent, :subquantities)
end

View File

@@ -86,6 +86,7 @@ module ApplicationHelper
def initialize(...)
super(...)
@default_options.merge!(@options.slice(:form))
@default_html_options.merge!(@options.slice(:form))
end
[:text_field, :password_field, :text_area].each do |selector|
@@ -170,6 +171,7 @@ 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

@@ -1,54 +1,15 @@
// Configure your import map in config/importmap.rb. Read more:
// https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
/* Hide page before loaded for testing purposes */
function showPage(event) {
document.documentElement.style.visibility="visible"
function showPage() {
document.documentElement.style.visibility = "visible"
}
document.addEventListener('turbo:load', showPage)
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 <details> 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 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) {
@@ -111,117 +72,3 @@ Turbo.StreamActions.unselect = function() {
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

View File

@@ -0,0 +1,7 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }

View File

@@ -0,0 +1,45 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "data"]
connect() {
const readouts = JSON.parse(this.dataTarget.textContent)
if (readouts.length === 0) return
const quantities = new Map()
readouts.forEach(r => {
if (!r.takenAt) return
if (!quantities.has(r.quantityId)) {
quantities.set(r.quantityId, { name: r.quantityName, unit: r.unit, x: [], y: [] })
}
const q = quantities.get(r.quantityId)
q.x.push(r.takenAt)
q.y.push(r.value)
})
const traces = []
quantities.forEach(q => {
traces.push({
x: q.x, y: q.y,
mode: 'lines+markers', type: 'scatter',
name: q.name + ' (' + q.unit + ')',
marker: { size: 5 }
})
})
const div = document.createElement('div')
div.className = 'chart-panel'
this.containerTarget.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 })
}
}

View File

@@ -0,0 +1,55 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["countLabel", "submitButton", "list"]
connect() {
this._observer = new MutationObserver(() => {
this.element.dispatchEvent(new Event('change', { bubbles: true }))
})
this._observer.observe(this.listTarget, { subtree: true, attributeFilter: ['disabled'] })
}
disconnect() {
this._observer?.disconnect()
}
change() {
const count = this.element.querySelectorAll('input:checked:not([disabled])').length
if (count > 0) {
this.countLabelTarget.textContent = count + ' selected'
Turbo.StreamElement.prototype.enableElement(this.submitButtonTarget)
} else {
this.countLabelTarget.textContent = this.countLabelTarget.dataset.prompt
Turbo.StreamElement.prototype.disableElement(this.submitButtonTarget)
}
}
close(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != this.element) {
this.element.removeAttribute("open")
}
}
processKey(event) {
switch (event.key) {
case "Escape":
if (this.element.hasAttribute("open")) {
this.element.removeAttribute("open")
event.stopPropagation()
}
break
case "Enter": {
const button = this.element.querySelector("button:not([disabled])")
if (button) {
button.click()
event.target.blur()
event.preventDefault()
event.stopPropagation()
}
break
}
}
}
}

View File

@@ -0,0 +1,62 @@
import { Controller } from "@hotwired/stimulus"
// Shared across all instances — drag spans multiple elements
let lastEnterTime
export default class extends Controller {
static values = {
dragPath: String,
dropId: String,
dropIdParam: String
}
start(event) {
lastEnterTime = event.timeStamp
this.element.closest("table").querySelectorAll("thead tr").forEach(tr => {
tr.toggleAttribute("hidden")
})
event.dataTransfer.setData("text/plain", this.dragPathValue)
const rect = this.element.getBoundingClientRect()
event.dataTransfer.setDragImage(this.element, event.x - rect.left, event.y - rect.top)
event.dataTransfer.dropEffect = "move"
}
end(event) {
this.leave(event)
this.element.closest("table").querySelectorAll("thead tr").forEach(tr => {
tr.toggleAttribute("hidden")
})
}
enter(event) {
this.leave(event)
lastEnterTime = event.timeStamp
document.getElementById(this.dropIdValue)?.classList.add("dropzone")
}
over(event) {
event.preventDefault()
}
leave(event) {
if (event.timeStamp <= lastEnterTime) return
this.element.closest("table").querySelectorAll(".dropzone").forEach(tr => {
tr.classList.remove("dropzone")
})
}
drop(event) {
event.preventDefault()
const id = this.dropIdValue.split("_").pop()
const form = document.createElement('form')
form.action = event.dataTransfer.getData("text/plain")
form.method = 'post'
form.dataset.turboStream = 'true'
const input = document.createElement('input')
input.type = 'hidden'; input.name = this.dropIdParamValue; input.value = id
form.appendChild(input)
form.addEventListener('turbo:submit-end', () => form.remove())
document.body.appendChild(form)
form.requestSubmit()
}
}

View File

@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
const autofocusEl = this.element.querySelector('[autofocus]')
if (autofocusEl) {
document.activeElement?.blur()
autofocusEl.focus()
}
}
processKey(event) {
switch (event.key) {
case "Escape":
this.element.querySelector("a[name=cancel]").click()
break
case "Enter":
this.element.querySelector("button[name=button]").click()
event.preventDefault()
break
}
}
validate(event) {
const id = event.submitter?.getAttribute("data-validate")
if (!id) return
const input = document.getElementById(id)
if (!input.checkValidity()) {
input.reportValidity()
event.preventDefault()
}
}
}

View File

@@ -0,0 +1,3 @@
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

View File

@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["tbody"]
connect() {
this.#group()
this._observer = new MutationObserver(() => this.#group())
this._observer.observe(this.tbodyTarget, {
childList: true, subtree: true,
attributes: true, attributeFilter: ['style']
})
}
disconnect() {
this._observer?.disconnect()
}
#group() {
let prevTakenAt = null
Array.from(this.tbodyTarget.querySelectorAll('tr[data-taken-at]'))
.filter(row => row.style.display !== 'none')
.forEach(row => {
const takenAt = row.dataset.takenAt
row.classList.toggle('grouped', takenAt !== null && takenAt === prevTakenAt)
prevTakenAt = takenAt
})
}
}

View File

@@ -0,0 +1,17 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
document.body.dataset.measurementsView = this.#get()
}
set(event) {
const view = event.params.name
localStorage.setItem('measurements-view', view)
document.body.dataset.measurementsView = view
}
#get() {
return localStorage.getItem('measurements-view') || 'compact'
}
}

View File

@@ -0,0 +1,36 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["select", "button"]
unitChanged() {
if (this.selectTarget.value && this.selectTarget.value !== this.selectTarget.dataset.defaultUnitId) {
Turbo.StreamElement.prototype.enableElement(this.buttonTarget)
} else {
Turbo.StreamElement.prototype.disableElement(this.buttonTarget)
}
}
setDefault() {
const select = this.selectTarget
const form = document.createElement('form')
form.action = this.buttonTarget.dataset.path
form.method = 'post'
form.dataset.turboStream = 'true'
const methodInput = document.createElement('input')
methodInput.type = 'hidden'; methodInput.name = '_method'; methodInput.value = 'patch'
const unitInput = document.createElement('input')
unitInput.type = 'hidden'; unitInput.name = 'quantity[default_unit_id]'; unitInput.value = select.value
form.appendChild(methodInput)
form.appendChild(unitInput)
form.addEventListener('turbo:submit-end', event => {
if (event.detail.success) {
select.dataset.defaultUnitId = select.value
this.unitChanged()
}
form.remove()
})
document.body.appendChild(form)
form.requestSubmit()
}
}

View File

@@ -1,9 +1,10 @@
class Quantity < ApplicationRecord
ATTRIBUTES = [:name, :description, :parent_id]
ATTRIBUTES = [:name, :description, :parent_id, :default_unit_id]
attr_cached :depth, :pathname
belongs_to :user, optional: true
belongs_to :parent, optional: true, class_name: "Quantity"
belongs_to :default_unit, optional: true, class_name: "Unit"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error

View File

@@ -1,5 +1,5 @@
class Readout < ApplicationRecord
ATTRIBUTES = [:quantity_id, :value, :unit_id]
ATTRIBUTES = [:quantity_id, :value, :unit_id, :taken_at]
belongs_to :user
belongs_to :quantity

View File

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

View File

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

@@ -0,0 +1,25 @@
<%= tabular_fields_for @readout, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form",
data: {controller: 'form', action: 'keydown->form#processKey',
form: form_tag, hidden_row: hidden_row, link: link} do %>
<td><%= @readout.quantity %></td>
<td class="ralign">
<%= form.number_field :value, required: true, autofocus: true %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id? ? 1 : 0) + u.symbol) },
{}, required: true %>
</td>
<td>
<%= form.datetime_field :taken_at %>
</td>
<td></td>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", measurements_path,
class: 'dangerous', name: :cancel,
onclick: render_turbo_stream('edit_form_close', {row: row}) %>
</td>
<% end %>
<% end %>

View File

@@ -0,0 +1,2 @@
<%= turbo_stream.close_form row %>
<%= turbo_stream.update :flashes %>

View File

@@ -0,0 +1,34 @@
<% 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| %>
<table class="items-table">
<tbody>
<%= tag.tr id: row, class: "form",
data: {controller: 'form', action: 'keydown->form#processKey',
form: form_tag, hidden_row: hidden_row} do %>
<td><%= @readout.quantity %></td>
<td class="ralign">
<%= form.number_field :value, required: true, autofocus: true %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id? ? 1 : 0) + u.symbol) },
{}, required: true %>
</td>
<td>
<%= form.datetime_field :taken_at %>
</td>
<td></td>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", measurements_path,
class: 'dangerous', name: :cancel,
onclick: render_turbo_stream('edit_form_close', {row: row}) %>
</td>
<% end %>
</tbody>
</table>
<% end %>

View File

@@ -1,6 +1,6 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form,
class: 'topside-area flex vertical center',
html: {onkeydown: 'formProcessKey(event)'} do |form| %>
html: {data: {controller: 'form', action: 'keydown->form#processKey'}} do |form| %>
<table class="items-table center">
<tbody id="readouts">
@@ -8,7 +8,7 @@
<tr class="italic">
<td class="hexpand hmin50"><%= t '.taken_at_html' %></td>
<td colspan="3" class="ralign">
<%= form.datetime_field :taken_at, required: true %>
<%= form.datetime_field :taken_at, required: true, value: Time.current.strftime('%Y-%m-%dT%H:%M') %>
</td>
</tr>
<% end %>
@@ -17,17 +17,18 @@
<%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open
onkeydown="detailsProcessKey(event)">
data-controller="details"
data-action="focusout->details#close change->details#change keydown->details#processKey">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<span data-prompt="<%= t('.select_quantity') %>" data-details-target="countLabel">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
data: {turbo_stream: true, details_target: 'submitButton'} %>
</summary>
<ul><%= quantities_check_boxes(@quantities) %></ul>
<ul data-details-target="list"><%= quantities_check_boxes(@quantities) %></ul>
</details>
<div class="flex reverse">
@@ -36,10 +37,3 @@
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>

View File

@@ -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 %>
<td>
<% if current_user.at_least(:active) %>
<%= link_to readout.quantity, edit_measurement_path(readout),
class: 'link', data: {turbo_stream: true} %>
<% else %>
<%= readout.quantity %>
<% end %>
</td>
<td class="ralign"><%= format("%.10g", readout.value) %></td>
<td><%= readout.unit %></td>
<td class="taken-at"><%= l(readout.taken_at) if readout.taken_at %></td>
<td class="created-at"><%= l(readout.created_at) %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<%= image_button_to t('.destroy'), 'delete-outline', measurement_path(readout),
method: :delete, data: {turbo_stream: true} %>
</td>
<% end %>
<% end %>

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',
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

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

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

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

View File

@@ -1,14 +1,41 @@
<%# TODO: show hint when no quantities/units defined %>
<div class="rightside-area buttongrid">
<div class="rightside-area buttongrid" data-controller="measurements-view">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
id: :new_measurement_link, onclick: 'this.blur();',
id: :new_measurement_link,
data: {turbo_stream: true} %>
<% end %>
<%= image_button_tag '', 'view-rows', name: nil, type: 'button',
class: 'view-toggle', title: t('.view_compact'),
data: {view: 'compact', action: 'click->measurements-view#set',
'measurements-view-name-param': 'compact'} %>
<%= image_button_tag '', 'view-columns', name: nil, type: 'button',
class: 'view-toggle', title: t('.view_wide'),
data: {view: 'wide', action: 'click->measurements-view#set',
'measurements-view-name-param': 'wide'} %>
</div>
<table class="main-area">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>
</table>
<div class="main-area measurements-section">
<%= tag.div id: :measurement_edit_form %>
<table class="items-table measurements-compact" data-controller="measurements">
<thead>
<tr>
<th><%= Quantity.model_name.human %></th>
<th><%= Readout.human_attribute_name(:value) %></th>
<th><%= Unit.model_name.human %></th>
<th data-column="taken-at"><%= Readout.human_attribute_name(:taken_at) %></th>
<th data-column="created-at"><%= Readout.human_attribute_name(:created_at) %></th>
<% if current_user.at_least(:active) %>
<th></th>
<% end %>
</tr>
</thead>
<tbody id="measurements" data-measurements-target="tbody">
<%= render(partial: 'readout', collection: @measurements, as: :readout) || render_no_items %>
</tbody>
</table>
<div id="measurements-wide" class="measurements-wide">
<%= render 'wide_table', wide_groups: @wide_groups, wide_quantities: @wide_quantities %>
</div>
</div>

View File

@@ -0,0 +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} %>

View File

@@ -1,6 +1,7 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<%- tag.tr id: row, class: "form",
data: {controller: 'form', action: 'keydown->form#processKey',
link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>">
<%= form.text_field :name, required: true, autofocus: true, size: 20 %>
@@ -8,6 +9,11 @@
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td>
<%= form.collection_select :default_unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id? ? 1 : 0) + u.symbol) },
{include_blank: true}, onchange: "this.dataset.changed = ''" %>
</td>
<td class="flex">
<%= form.button %>

View File

@@ -1,20 +1,22 @@
<%= tag.tr id: dom_id(quantity),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %>
draggable: true,
data: {controller: 'drag',
action: 'dragstart->drag#start dragend->drag#end dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave',
drag_drag_path_value: reparent_quantity_path(quantity),
drag_drop_id_value: dom_id(quantity),
drag_drop_id_param_value: 'quantity[parent_id]'} do %>
<td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %>
data: {turbo_stream: true} %>
</td>
<td><%= quantity.description %></td>
<td><%= quantity.default_unit&.symbol %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
id: dom_id(quantity, :new, :link), data: {turbo_stream: true} %>
<%= image_button_to_if quantity.destroyable?, t('.destroy'), 'delete-outline',
quantity_path(quantity), method: :delete %>

View File

@@ -1,7 +1,7 @@
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
id: dom_id(Quantity, :new, :link),
data: {turbo_stream: true} %>
<% end %>
<%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
@@ -16,16 +16,18 @@
<tr>
<th><%= Quantity.human_attribute_name(:name) %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th>
<th><%= Quantity.human_attribute_name(:default_unit) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "quantity_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
<th colspan="4"><%= t '.top_level_drop' %></th>
data: {controller: 'drag',
action: 'dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave',
drag_drop_id_value: 'quantity_',
drag_drop_id_param_value: 'quantity[parent_id]'} do %>
<th colspan="5"><%= t '.top_level_drop' %></th>
<% end %>
</thead>
<tbody id="quantities">

View File

@@ -1,6 +1,6 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout),
data: {controller: 'readout-unit'} do %>
<td>
<%# TODO: add grayed readout index (in separate column?) %>
<%= readout.quantity.relative_pathname(@superquantity) %>
@@ -12,10 +12,19 @@
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: '', disabled: '', selected: ''}, required: true %>
{prompt: '', disabled: '', selected: readout.quantity.default_unit_id || ''}, required: true,
data: {default_unit_id: readout.quantity.default_unit_id || '',
readout_unit_target: 'select',
action: 'change->readout-unit#unitChanged'} %>
</td>
<td class="flex">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'check-circle-outline',
class: 'set-default-unit', name: nil, type: 'button', disabled: true,
title: t('readouts.form.set_default_unit'),
data: {path: quantity_path(readout.quantity),
readout_unit_target: 'button',
action: 'click->readout-unit#setDefault'} %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>

View File

@@ -1,6 +1,7 @@
<%= tabular_fields_for @unit, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<%- tag.tr id: row, class: "form",
data: {controller: 'form', action: 'keydown->form#processKey',
link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @unit.base_id? ? 1 : 0 %>">
<%= form.text_field :symbol, required: true, autofocus: true, size: 12 %>

View File

@@ -1,13 +1,13 @@
<%= tag.tr id: dom_id(unit),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: rebase_unit_path(unit),
drop_id: dom_id(unit.base || unit),
drop_id_param: "unit[base_id]"} do %>
draggable: true,
data: {controller: 'drag',
action: 'dragstart->drag#start dragend->drag#end dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave',
drag_drag_path_value: rebase_unit_path(unit),
drag_drop_id_value: dom_id(unit.base || unit),
drag_drop_id_param_value: 'unit[base_id]'} do %>
<td style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), class: 'link', onclick: 'this.blur();',
<%= link_to unit, edit_unit_path(unit), class: 'link',
data: {turbo_stream: true} %>
</td>
<td><%= unit.description %></td>
@@ -17,7 +17,7 @@
<td class="flex">
<% unless unit.base_id? %>
<%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
id: dom_id(unit, :new, :link), data: {turbo_stream: true} %>
<% end %>
<%= image_button_to_if unit.movable?, t('.destroy'), 'delete-outline', unit_path(unit),

View File

@@ -1,7 +1,7 @@
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path,
id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
id: dom_id(Unit, :new, :link), data: {turbo_stream: true} %>
<% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path,
class: 'tools-area' %>
@@ -22,9 +22,10 @@
<% end %>
</tr>
<%= tag.tr id: "unit_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "unit_", drop_id_param: "unit[base_id]"} do %>
data: {controller: 'drag',
action: 'dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave',
drag_drop_id_value: 'unit_',
drag_drop_id_param_value: 'unit[base_id]'} do %>
<th colspan="5"><%= t '.top_level_drop' %></th>
<% end %>
</thead>

View File

@@ -1,5 +1,5 @@
<%= labeled_form_for resource, url: user_registration_path,
html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
html: {class: 'main-area', data: {controller: 'form', action: 'submit->form#validate'}} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>

View File

@@ -1,5 +1,5 @@
<%= labeled_form_for resource, url: user_session_path,
html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
html: {class: 'main-area', data: {controller: 'form', action: 'submit->form#validate'}} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>

View File

@@ -20,10 +20,6 @@ Bundler.require(*Rails.groups)
module FixinMe
class Application < Rails::Application
# Allow RAILS_DATABASE_YML to override the database config file path.
# Used by `rails test:all_databases` to test against multiple DB adapters.
config.paths['config/database'] = [ENV['RAILS_DATABASE_YML']] if ENV['RAILS_DATABASE_YML']
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0

View File

@@ -1 +1 @@
3nm9KZNtyLhPgZBVzOOkN2FXHD0uEMuzgb5Sl1MrAMmi6+iEFSzyTHfZFW2mz18VyNz5DDYvTODZqBDQKK+FQh70uEQkmGqaY5XsTOzUFzk56quaPNtZvFEGux1nX2avSbYQBs3HeyYyWyTAFhez5j8tVb6sZD2xZ8twa9KAB42j86NIHT9w/ZMFqZbGbdBoR1Mrqoy9/IWv2QgxMTpGR6JBpTUwauXm6wS/bTt8SCXF57JSVgvdw/BxFzoA3Xj6N5E89LbMfh54W2ruMhybka5E7zXN9z0v4oXt8GiYZFIODEYZwqzEVaUK1WXS5qb5OrDJFAzs29Uf/gDrIDx71Lot+jejCS+xFfI9454EnHcVH66wKuwF6ylKupJDffM0hQHplcEfVSq5UiDfbPXm46Vr0g1A--2RrmuzCBuHvYpPNA--ugbuRe7ivfDqeUCt6ahciA==
yQ/e5AEwReoZ6yiIqCZjBbl2Tp41JNcuwfWF3FeSSk2K0XBtE+VQXHlAHMBPRwbBdkutB8jls+YKou3JX58j88BEH3Ft/8h7GIepYF+nOhdb79y05lqEhARA4IZYnHe1Do72MdmseE0ectfDpfk6Q1qnfiTFe3X1KyfLR0hiSEM5+1ZYfk2loUBWSIfgYuqtK7bEOZiL6imU46n4+58g3VZd0cK7getT7rwNlVt1s6ME9PTwT/RqE736fLEIyDeaEg8hBxTrPVeYyii2o4IWM02/0HsuRPxXLLQAgXyzHhlT9wo18X5FaaecgGloBie0UMrPS3j6oBlVn61WQbkuEe/yQKnzyiw0v5HSmzME4PiDTaSW2em/BtGiMAhJpyukipQa4/leR3OTJxv3TAMha1bnk/OC--QU+gjSEvBsZpr3XT--osCoTfqZ4ENeas+nFdXefA==

View File

@@ -48,26 +48,3 @@ production:
#test:
# <<: *default
# database: fixinme_test
# Multi-database testing — `bundle exec rails test:all_databases`
# ---------------------------------------------------------------
# Add any number of `test_<name>:` blocks to run the full test suite
# against additional database adapters in a single command.
# Each adapter's gem must be available in the bundle:
# bundle config --local with "mysql:sqlite" # mysql + sqlite
# bundle config --local with "mysql:pg" # mysql + postgresql
#
# Example — run tests against MySQL and SQLite:
#
#test_sqlite:
# adapter: sqlite3
# database: db/fixinme_test.sqlite3
#
# Example — run tests against MySQL and PostgreSQL:
#
#test_pg:
# adapter: postgresql
# database: fixinme_test
# username: fixinme
# password: Some-password1%
# host: localhost

View File

@@ -58,4 +58,7 @@ Rails.application.configure do
# config.action_view.annotate_rendered_view_with_filenames = true
config.log_level = :info
# Allow Capybara's dynamic test server host (127.0.0.1:<random_port>)
config.hosts << '127.0.0.1'
end

View File

@@ -2,3 +2,6 @@
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"

View File

@@ -11,8 +11,13 @@ en:
activerecord:
attributes:
quantity:
default_unit: Default unit
description: Description
name: Name
readout:
created_at: Recorded at
taken_at: Taken at
value: Value
unit:
base: Base unit
description: Description
@@ -81,6 +86,11 @@ en:
revert: Revert
sign_out: Sign out
source_code: Get code
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.
@@ -89,6 +99,21 @@ en:
taken_at_html: Measurement taken at&emsp;
index:
new_measurement: Add measurement
view_compact: Compact view
view_wide: Wide view
view_charts: Charts
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:
navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults.

View File

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

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

@@ -0,0 +1,5 @@
class AddDefaultUnitToQuantities < ActiveRecord::Migration[7.2]
def change
add_reference :quantities, :default_unit, foreign_key: {to_table: :units}, null: true
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
ActiveRecord::Schema[7.2].define(version: 2026_04_03_000000) do
create_table "quantities", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id"
t.string "name", limit: 31, null: false
@@ -20,6 +20,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false
t.bigint "default_unit_id"
t.index ["default_unit_id"], name: "index_quantities_on_default_unit_id"
t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id"
@@ -32,10 +34,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
t.decimal "value", precision: 30, scale: 15, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "taken_at"
t.index ["quantity_id", "created_at"], name: "index_readouts_on_quantity_id_and_created_at", unique: true
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|
@@ -70,6 +74,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
end
add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "units", column: "default_unit_id"
add_foreign_key "quantities", "users"
add_foreign_key "readouts", "quantities"
add_foreign_key "readouts", "units"

View File

@@ -21,3 +21,4 @@ end
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
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

@@ -1,118 +0,0 @@
require 'yaml'
require 'erb'
require 'tmpdir'
namespace :test do
desc <<~DESC
Run the full test suite against every test database configured in database.yml.
Any top-level key that starts with "test" and contains a Hash is treated as a
test database configuration:
test: # always required — the primary test database
adapter: mysql2
database: fixinme_test
...
test_sqlite: # optional additional databases
adapter: sqlite3
database: db/fixinme_test.sqlite3
test_pg:
adapter: postgresql
database: fixinme_test
...
For each database the task will:
1. Check that the required adapter gem is available (skip with warning if not).
2. Run `rails db:test:prepare` to create/migrate the database.
3. Run `rails test` and record pass/fail.
A summary is printed at the end. The task exits non-zero if any database fails.
DESC
task :all_databases do
db_file = Rails.root.join('config', 'database.yml')
all = YAML.safe_load(ERB.new(db_file.read).result, aliases: true) || {}
test_cfgs = all.select { |k, v| k.to_s.start_with?('test') && v.is_a?(Hash) }
non_test = all.reject { |k, _| k.to_s.start_with?('test') }
abort "No test database configurations found in #{db_file}." if test_cfgs.empty?
results = {}
test_cfgs.each do |name, config|
adapter = config['adapter'].to_s
puts "\n#{'─' * 64}"
puts " #{name} (adapter: #{adapter})"
puts '─' * 64
unless adapter_available?(adapter)
puts " SKIPPED — gem for '#{adapter}' adapter not available in bundle."
puts " Add it to Gemfile (e.g. `bundle config --local with #{adapter_group(adapter)}`)"
results[name] = :skipped
next
end
Dir.mktmpdir('rails_test_db_') do |tmpdir|
tmp_yml = File.join(tmpdir, 'database.yml')
# Write a standalone database.yml with just this config as `test:`
# (non-test configs are preserved so boot doesn't fail on `production:` lookup)
File.write(tmp_yml, non_test.merge('test' => config).to_yaml)
env = { 'RAILS_DATABASE_YML' => tmp_yml }
if system(env, 'bundle exec rails db:test:prepare')
results[name] = system(env, 'bundle exec rails test') ? :pass : :fail
else
results[name] = :prepare_failed
end
end
end
puts "\n#{'═' * 64}"
puts " SUMMARY"
puts '═' * 64
results.each do |name, status|
adapter = test_cfgs[name]['adapter']
icon = { pass: '✓', fail: '✗', prepare_failed: '✗', skipped: '' }[status]
label = { pass: 'PASS', fail: 'FAIL',
prepare_failed: 'PREPARE FAILED', skipped: 'SKIPPED' }[status]
puts " #{icon} #{name.ljust(26)} (#{adapter.ljust(12)}) #{label}"
end
puts '═' * 64
failed = results.count { |_, s| s == :fail || s == :prepare_failed }
abort "\n #{failed} database(s) failed." if failed > 0
end
end
private
# Returns true if the gem required for this adapter is loadable.
ADAPTER_GEMS = {
'mysql2' => 'mysql2',
'sqlite3' => 'sqlite3',
'postgresql' => 'pg',
'pg' => 'pg',
}.freeze
def adapter_available?(adapter)
gem_name = ADAPTER_GEMS.fetch(adapter, adapter)
require gem_name
true
rescue LoadError
false
end
# Returns the Bundler group name that installs the adapter gem.
ADAPTER_GROUPS = {
'mysql2' => 'mysql',
'sqlite3' => 'sqlite',
'postgresql' => 'postgresql',
'pg' => 'postgresql',
}.freeze
def adapter_group(adapter)
ADAPTER_GROUPS.fetch(adapter, adapter)
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

@@ -1,8 +1,65 @@
require "test_helper"
class MeasurementsControllerTest < ActionDispatch::IntegrationTest
#test "should get index" do
# get measurements_index_url
# assert_response :success
#end
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 "index returns ok" do
get measurements_path
assert_response :success
end
test "index requires authentication" do
delete destroy_user_session_path
get measurements_path
assert_response :redirect
end
test "create records readout with taken_at" do
taken_at = 1.day.ago.change(usec: 0)
assert_difference -> { @user.readouts.count } do
post measurements_path, params: {
taken_at: taken_at.iso8601,
readouts: [{ quantity_id: @quantity.id, value: '82.5', unit_id: @unit.id }]
}, as: :turbo_stream
end
assert_response :success
assert_equal taken_at, @user.readouts.last.taken_at
end
test "create with no readouts selected shows alert" do
post measurements_path, params: { taken_at: Time.now.iso8601 }, as: :turbo_stream
assert_response :success
end
test "destroy removes readout" do
readout = @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago)
assert_difference -> { @user.readouts.count }, -1 do
delete measurement_path(readout), as: :turbo_stream
end
assert_response :success
end
test "destroy cannot remove another user's readout" do
other_quantity = users(:bob).quantities.create!(name: 'Weight')
other_unit = users(:bob).units.create!(symbol: 'kg')
readout = users(:bob).readouts.create!(quantity: other_quantity, unit: other_unit, value: 70.0, taken_at: 1.day.ago)
assert_no_difference -> { users(:bob).readouts.count } do
delete measurement_path(readout), as: :turbo_stream
end
end
test "update changes readout value" do
readout = @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago)
patch measurement_path(readout), params: {
readout: { value: '83.0', unit_id: @unit.id, taken_at: readout.taken_at.iso8601 }
}, as: :turbo_stream
assert_response :success
assert_in_delta 83.0, readout.reload.value
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

View File

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

View File

@@ -0,0 +1,45 @@
require "application_system_test_case"
class QuantitiesTest < ApplicationSystemTestCase
setup do
@user = sign_in(user: users(:alice))
@unit = @user.units.create!(symbol: 'kg')
@quantity = @user.quantities.create!(name: 'Weight')
visit quantities_path
end
test "update button turns red when default unit changes" do
click_on 'Weight'
button = find('button[name=button]')
initial_color = evaluate_script("getComputedStyle(arguments[0]).backgroundColor", button)
select 'kg', from: 'quantity[default_unit_id]'
changed_color = evaluate_script("getComputedStyle(arguments[0]).backgroundColor", button)
refute_equal initial_color, changed_color, "Button color should change when default unit is altered"
end
test "saving default unit pre-selects it in measurements form" do
click_on 'Weight'
select 'kg', from: 'quantity[default_unit_id]'
click_on t('helpers.submit.update')
assert_selector '.flash.notice'
@quantity.reload
assert_equal @unit.id, @quantity.default_unit_id
visit measurements_path
find(:link_or_button, t('measurements.index.new_measurement')).click
assert_selector '#measurement_form'
within '#quantity_select' do
check 'Weight'
end
find('button[formaction]').click
within 'tbody#readouts' do
assert_selector "option[value='#{@unit.id}'][selected]"
end
end
end

View File

@@ -229,7 +229,7 @@ class UsersTest < ApplicationSystemTestCase
user = User.find_by_email!(first(:link).text)
inject_button_to first('td:not(.link)'), "update status", user_path(user), method: :patch,
params: {user: {status: User.statuses.keys.sample}}, data: {turbo: false}
click_on "update status"
execute_script("arguments[0].click()", find_button("update status"))
end
assert_title 'The change you wanted was rejected (422)'
end