Compare commits

..

29 Commits

Author SHA1 Message Date
cryptogopher 4d86652247 bundle update 2026-10-04 01:26:16 +02:00
cryptogopher eae0ee9d20 Improve controllers and views 2026-07-28 00:46:33 +02:00
cryptogopher eb2797bd90 Display form errors using custom validity messages 2026-07-28 00:45:08 +02:00
cryptogopher 5d051de666 Check for out of range float values
Remove unsupported attribute `[maxlength]` from `input[number]`.
2026-07-20 21:45:33 +02:00
cryptogopher ef8214cfa7 Improve tests, extend fixtures 2026-07-20 16:38:05 +02:00
cryptogopher 30c80e6e2e Fix js code and comment formatting 2026-07-12 17:20:00 +02:00
cryptogopher 159b4b340f Test Unit using float multiplier
Improve string randomization. Use Capybara table selectors.
2026-07-12 17:20:00 +02:00
cryptogopher 774b1eb19b Use [disabled] attribute only on tags that support it
Add Capybara selector for disabled links.
2026-07-12 17:20:00 +02:00
cryptogopher d4be84875e Use <pre> tag for formatting 2026-07-12 17:19:50 +02:00
cryptogopher 65e5643b13 Remove pictograms directory 2026-07-12 17:17:59 +02:00
cryptogopher b05523dcd6 Change Unit.multiplier type from decimal to float
Remove remnants related to BigDecimal.
2026-07-12 17:16:43 +02:00
cryptogopher 1ab925f6fa Improve Units tests and remove test parallelization
Parallelization produces errors (#92)
2026-05-15 00:21:10 +02:00
cryptogopher 83168092f1 Fix SQLite query compatibility inside WITH clause
SQLite3::SQLException: circular reference
2026-05-15 00:21:01 +02:00
cryptogopher 852e6e7cea Fix test passing blank input into required field 2026-05-15 00:20:28 +02:00
cryptogopher 0481b0f6f1 Allow Capybara test server IP in HostAuthorization 2026-05-07 20:53:18 +02:00
cryptogopher 76b95f7c30 Set default :text type column limit 2026-05-07 18:20:06 +02:00
cryptogopher ca7edb99e1 Add Note 2026-05-07 18:17:30 +02:00
cryptogopher 98044fc04a Remove empty test files 2026-05-05 23:41:08 +02:00
cryptogopher d3a34233b9 Set constraints on Readouts.user_id foreign key
Add Measurements table
2026-05-05 20:36:57 +02:00
cryptogopher 4a36ffc8bf Move migration defaults to ActiveRecord migration strategy
Remove redundant defaults
2026-05-05 20:36:51 +02:00
cryptogopher 78639b4c1e Upgrade Rails to 8.1.3 2026-05-05 20:35:00 +02:00
cryptogopher c4def9cd86 Filter :email field logging only in production env 2026-04-30 18:26:43 +02:00
cryptogopher 3454d3052b Generate admin password on db:seed
Test admin account creation in db:seed
2026-04-30 18:23:00 +02:00
cryptogopher dedea0246f Run tests on multiple database configurations 2026-04-27 17:38:49 +02:00
cryptogopher 3ac3a6f13c Drop Readout.value decimal type in favor of float 2026-04-25 18:20:16 +02:00
cryptogopher 97e7bfedf7 Upgrade Rails to 8.0.5
Install sqlite3
Migrate Rails default settings from 7.2 to 8.0
2026-04-25 04:13:47 +02:00
cryptogopher a05767213c Fix test based on unavailable td.link selector 2026-04-25 04:13:47 +02:00
cryptogopher d002ae0f2c Clean up and improve items-table styling
Closes #9
2026-04-25 04:13:47 +02:00
cryptogopher f87aa9fd3d Improve Measurement form layout and styling 2026-04-25 04:13:47 +02:00
159 changed files with 1790 additions and 1995 deletions
+5 -1
View File
@@ -15,6 +15,9 @@
/config/master.key /config/master.key
/config/puma.rb /config/puma.rb
# Ignore test database.
/db/test.sqlite3
# Ignore all logfiles and tempfiles. # Ignore all logfiles and tempfiles.
/log/* /log/*
/tmp/* /tmp/*
@@ -30,7 +33,7 @@
/tmp/restart.txt /tmp/restart.txt
# Ignore user files # Ignore user files.
/.bash_history /.bash_history
/.byebug_history /.byebug_history
/.config /.config
@@ -38,6 +41,7 @@
/.lesshst /.lesshst
/.local /.local
/.mysql_history /.mysql_history
/.sqlite_history
/.ssh /.ssh
/.vim /.vim
/.viminfo /.viminfo
-122
View File
@@ -1,122 +0,0 @@
# 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
+45 -3
View File
@@ -22,13 +22,55 @@ whenever a change is considered, to avoid regressions.
and scale approximately half of that - 9, and scale approximately half of that - 9,
* double precision floating point guarantees 15 digits of precision, which * double precision floating point guarantees 15 digits of precision, which
is more than enough for all expected use cases, is more than enough for all expected use cases,
* if a decimal string with at most 15 significant digits is converted to
the IEEE 754 double-precision format, giving a normal number, and then
converted back to a decimal string with the same number of digits, the
final result should match the original string,
* if an IEEE 754 double-precision number is converted to a decimal
string with at least 17 significant digits, and then converted back to
double-precision representation, the final result must match the
original number,
* single precision floating point only guarntees 6 digits of precision, * single precision floating point only guarntees 6 digits of precision,
which is estimated to be too low for some use cases (e.g. storing which is estimated to be too low for some use cases (e.g. storing
latitude/longitude with a resolution grater than 100m) latitude/longitude with a resolution grater than 100m),
* double precision floating point (IEEE 754) is a standard that ensures * double precision floating point (IEEE 754) is a standard that ensures
compatibility with all database engines, compatibility with majority of database engines,
* the same data format is used internally by Ruby as a `Float`; it * the same data format is used internally by Ruby as a `Float`; it
guarantees no conversions between storage and computation, guarantees no conversions between storage and computation,
* as a standard with hardware implementations ensures both: computing * as a standard with hardware implementations ensures both: computing
efficiency and hardware/3rd party library compatibility as opposed to Ruby efficiency and hardware/3rd party library compatibility as opposed to Ruby
custom `BigDecimal` type custom `BigDecimal` type,
* at present, only normalized numbers are used and considered sufficient, in
order to avoid potential issues with the cross-platform compatibility of
subnormal (denormal) numbers,
* in the future, the IEEE 754 decimal64 data type may be considered once it is
supported by database engines.
### Database layer vs application layer data model constraints
* database constraints are the final guard against data integrity corruption,
* they should safeguard against data referential integrity loss under _all_
data (not schema) manipulation scenarios, including application level
logic errors and direct data manipulation (e.g. through `dbconsole`),
* application constraints can be as restrictive as database constraints or more,
but not less, as it doesn't serve any use case,
* proper application level constraints should prevent unhandled database
exception occurences, e.g `ActiveRecord::InvalidForeignKey` for operations
performed through Models (i.e. not `#delete_all` etc.)
### User interface
* values provided by user have to be exactly preserved, avoiding any precision
loss due to e.g. type casting
* when it's not possible to store value without information loss, feedback has to
be given at the time of entering value
* values can be received using either:
* `input[number]` - validates: number format, value against `[min]` and
`[max]` attributes; cannot validate precision (no `[pattern]` support);
or
* `input[text]` - can validate number format and precision with
`[pattern]`; cannot validate range (no `[min]`/`[max]` support),
* none of the above prevents misinterpretation of values between 0 and
Float::MIN as 0.0 - this has to be checked manually,
* assuming that user input with _precision > DIG_ is as rare as in
the range _(0, MIN)_, the decision is to choose input which requires
the least/simplest additional code.
+8 -12
View File
@@ -1,14 +1,14 @@
source "https://rubygems.org" source "https://rubygems.org"
# The requirement for the Ruby version comes from Rails # The requirement for the Ruby version comes from Rails.
gem "rails", "~> 7.2.3" gem "rails", "~> 8.1.4"
gem "sprockets-rails" gem "sprockets-rails"
gem "puma", "~> 6.0" gem "puma", "~> 6.0"
gem "sassc-rails" gem "sassc-rails"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
# TODO: select db gems automatically # TODO: select db gems automatically?
# database_config = ERB.new(File.read("config/database.yml")).result # database_config = ERB.new(File.read("config/database.yml")).result
# YAML.load(database_config, aliases: true).values.map { |env| env["adapter"] }.uniq # YAML.load(database_config, aliases: true).values.map { |env| env["adapter"] }.uniq
group :mysql, optional: true do group :mysql, optional: true do
@@ -21,11 +21,10 @@ group :sqlite, optional: true do
gem "sqlite3", "~> 2.7" gem "sqlite3", "~> 2.7"
end end
gem "devise" gem "devise", "~> 5.0.4"
gem "importmap-rails" gem "importmap-rails"
gem "turbo-rails", "~> 2.0" gem "turbo-rails", "~> 2.0"
gem "stimulus-rails"
group :development, :test do group :development, :test do
gem "byebug" gem "byebug"
@@ -43,11 +42,8 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
# Remove minitest version restriction after error fixed:
# railties-7.2.3/lib/rails/test_unit/line_filtering.rb:7:in `run':
# wrong number of arguments (given 3, expected 1..2) (ArgumentError)
# from /var/www/.gem/ruby/3.3.0/gems/minitest-6.0.2/lib/minitest.rb:473:in
# `block (2 levels) in run_suite'
gem "minitest", "< 6"
end end
# TODO: remove after upgrade to next version containing PR:
# https://github.com/ruby-i18n/i18n/pull/755
gem "i18n", git: "https://github.com/ruby-i18n/i18n", branch: "delegate-missing-interpolation-handler"
+151 -148
View File
@@ -1,85 +1,93 @@
GIT
remote: https://github.com/ruby-i18n/i18n
revision: 8850d979a3a32453123ed744b943ead8751ba73b
branch: delegate-missing-interpolation-handler
specs:
i18n (1.15.2)
concurrent-ruby (~> 1.0)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.2.3) action_text-trix (2.1.19)
actionpack (= 7.2.3) railties
activesupport (= 7.2.3) actioncable (8.1.4)
actionpack (= 8.1.4)
activesupport (= 8.1.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.2.3) actionmailbox (8.1.4)
actionpack (= 7.2.3) actionpack (= 8.1.4)
activejob (= 7.2.3) activejob (= 8.1.4)
activerecord (= 7.2.3) activerecord (= 8.1.4)
activestorage (= 7.2.3) activestorage (= 8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (7.2.3) actionmailer (8.1.4)
actionpack (= 7.2.3) actionpack (= 8.1.4)
actionview (= 7.2.3) actionview (= 8.1.4)
activejob (= 7.2.3) activejob (= 8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.2.3) actionpack (8.1.4)
actionview (= 7.2.3) actionview (= 8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
cgi
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc rack (>= 2.2.4)
rack (>= 2.2.4, < 3.3)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (7.2.3) actiontext (8.1.4)
actionpack (= 7.2.3) action_text-trix (~> 2.1.15)
activerecord (= 7.2.3) actionpack (= 8.1.4)
activestorage (= 7.2.3) activerecord (= 8.1.4)
activesupport (= 7.2.3) activestorage (= 8.1.4)
activesupport (= 8.1.4)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.2.3) actionview (8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
builder (~> 3.1) builder (~> 3.1)
cgi
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (7.2.3) activejob (8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.2.3) activemodel (8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
activerecord (7.2.3) activerecord (8.1.4)
activemodel (= 7.2.3) activemodel (= 8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.2.3) activestorage (8.1.4)
actionpack (= 7.2.3) actionpack (= 8.1.4)
activejob (= 7.2.3) activejob (= 8.1.4)
activerecord (= 7.2.3) activerecord (= 8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.2.3) activesupport (8.1.4)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
json
logger (>= 1.4.2) logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.8) uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.21) bcrypt (3.1.22)
benchmark (0.5.0) bigdecimal (4.1.3)
bigdecimal (4.0.1)
bindex (0.8.1) bindex (0.8.1)
builder (3.3.0) builder (3.3.0)
byebug (13.0.0) byebug (13.0.0)
@@ -93,155 +101,155 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
cgi (0.5.1) concurrent-ruby (1.3.8)
concurrent-ruby (1.3.6)
connection_pool (3.0.2) connection_pool (3.0.2)
crass (1.0.6) crass (1.0.7)
date (3.5.1) date (3.5.1)
devise (5.0.2) devise (5.0.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 7.0) railties (>= 7.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
drb (2.2.3) drb (2.2.3)
erb (6.0.2) erb (6.0.7)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl) ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu) ffi (1.17.4-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl) ffi (1.17.4-arm-linux-musl)
ffi (1.17.3-arm64-darwin) ffi (1.17.4-arm64-darwin)
ffi (1.17.3-x86_64-darwin) ffi (1.17.4-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl) ffi (1.17.4-x86_64-linux-musl)
globalid (1.3.0) globalid (1.4.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.3) importmap-rails (2.2.3)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.2) io-console (0.9.4)
irb (1.17.0) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0) prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
json (3.0.2)
logger (1.7.0) logger (1.7.0)
loofah (2.25.0) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.1)
logger logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.2.1)
matrix (0.4.3) matrix (0.4.3)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.27.0) minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mysql2 (0.5.7) mysql2 (0.5.7)
bigdecimal bigdecimal
net-imap (0.6.3) net-imap (0.6.7)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.4.0)
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.2)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-gnu) nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-musl) nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin) nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
pg (1.6.3) pg (1.7.0)
pg (1.6.3-aarch64-linux) pg (1.7.0-aarch64-linux)
pg (1.6.3-aarch64-linux-musl) pg (1.7.0-aarch64-linux-musl)
pg (1.6.3-arm64-darwin) pg (1.7.0-arm64-darwin)
pg (1.6.3-x86_64-darwin) pg (1.7.0-x86_64-darwin)
pg (1.6.3-x86_64-linux) pg (1.7.0-x86_64-linux)
pg (1.6.3-x86_64-linux-musl) pg (1.7.0-x86_64-linux-musl)
pp (0.6.3) pp (0.6.4)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.9.0) prism (1.9.0)
psych (5.3.1) public_suffix (7.0.5)
date
stringio
public_suffix (7.0.2)
puma (6.6.1) puma (6.6.1)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.5) rack (3.2.7)
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (7.2.3) rails (8.1.4)
actioncable (= 7.2.3) actioncable (= 8.1.4)
actionmailbox (= 7.2.3) actionmailbox (= 8.1.4)
actionmailer (= 7.2.3) actionmailer (= 8.1.4)
actionpack (= 7.2.3) actionpack (= 8.1.4)
actiontext (= 7.2.3) actiontext (= 8.1.4)
actionview (= 7.2.3) actionview (= 8.1.4)
activejob (= 7.2.3) activejob (= 8.1.4)
activemodel (= 7.2.3) activemodel (= 8.1.4)
activerecord (= 7.2.3) activerecord (= 8.1.4)
activestorage (= 7.2.3) activestorage (= 8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.2.3) railties (= 8.1.4)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0) rails-html-sanitizer (1.7.1)
loofah (~> 2.25) loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.2.3) railties (8.1.4)
actionpack (= 7.2.3) actionpack (= 8.1.4)
activesupport (= 7.2.3) activesupport (= 8.1.4)
cgi
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2) tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rake (13.3.1) rake (13.4.2)
rdoc (7.2.0) rbs (4.2.0)
erb logger
psych (>= 4.0.0) prism (>= 1.6.0)
tsort tsort
regexp_parser (2.11.3) rdoc (8.1.0)
reline (0.6.3) erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
regexp_parser (2.13.1)
reline (0.7.0)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.2.0) responders (3.2.1)
actionpack (>= 7.0) actionpack (>= 7.0)
railties (>= 7.0) railties (>= 7.0)
rexml (3.4.4) rubyzip (3.7.0)
rubyzip (3.2.2)
sassc (2.4.0) sassc (2.4.0)
ffi (~> 1.9) ffi (~> 1.9)
sassc-rails (2.1.2) sassc-rails (2.1.2)
@@ -251,56 +259,52 @@ GEM
sprockets-rails sprockets-rails
tilt tilt
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.41.0) selenium-webdriver (4.50.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sprockets (4.2.2) sprockets (4.4.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.1)
logger logger
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2) sprockets-rails (3.5.2)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (2.9.0-aarch64-linux-gnu) sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl) sqlite3 (2.9.6-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu) sqlite3 (2.9.6-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl) sqlite3 (2.9.6-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin) sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.0-x86_64-darwin) sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.9.6-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl) sqlite3 (2.9.6-x86_64-linux-musl)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0) thor (1.5.0)
tilt (2.7.0) tilt (2.9.0)
timeout (0.6.0) timeout (0.6.1)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.23) turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.3.0)
actionview (>= 6.0.0) actionview (>= 8.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 8.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.5) zeitwerk (2.8.3)
PLATFORMS PLATFORMS
aarch64-linux-gnu aarch64-linux-gnu
@@ -315,18 +319,17 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
byebug byebug
capybara capybara
devise devise (~> 5.0.4)
i18n!
importmap-rails importmap-rails
minitest (< 6)
mysql2 (~> 0.5) mysql2 (~> 0.5)
pg (~> 1.5) pg (~> 1.5)
puma (~> 6.0) puma (~> 6.0)
rails (~> 7.2.3) rails (~> 8.1.4)
sassc-rails sassc-rails
selenium-webdriver selenium-webdriver
sprockets-rails sprockets-rails
sqlite3 (~> 2.7) sqlite3 (~> 2.7)
stimulus-rails
turbo-rails (~> 2.0) turbo-rails (~> 2.0)
tzinfo-data tzinfo-data
web-console web-console
+43 -17
View File
@@ -16,14 +16,10 @@ environment, see the _Contributing_ section below.
* Server side: * Server side:
* Ruby interpreter, depending on the version of Rails used (see _Gemfile_), * Ruby interpreter, depending on the version of Rails used (see _Gemfile_),
* https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#ruby-versions * https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#ruby-versions
* database (e.g. MySQL >= 8.0) supporting: * database (e.g. SQLite, MySQL >= 8.0) supporting:
* recursive Common Table Expressions (CTE) for SELECT/UPDATE/DELETE, * recursive Common Table Expressions (CTE) for SELECT/UPDATE/DELETE,
* MariaDB does not support CTE for UPDATE/DELETE * MariaDB does not support CTE for UPDATE/DELETE
(https://jira.mariadb.org/browse/MDEV-18511) (https://jira.mariadb.org/browse/MDEV-18511)
* decimal datatype with precision of at least 30,
* SQLite3 _flexible typing_ decimal will work, but precision
will be limited to 16, making it practical mostly for testing
purposes
* for testing: browser as specified in _Client side_ requirements * for testing: browser as specified in _Client side_ requirements
* Client side: * Client side:
* browser (e.g. Firefox >= 121) supporting: * browser (e.g. Firefox >= 121) supporting:
@@ -89,14 +85,10 @@ Running
### Standalone Rails server + Apache proxy ### Standalone Rails server + Apache proxy
Customize Puma config template: Copy and customize Puma config template if required:
cp -a config/puma.rb.dist config/puma.rb cp -a config/puma.rb.dist config/puma.rb
and specify server IP/port, either with `port` or `bind`, e.g.:
bind 'tcp://0.0.0.0:3000'
#### (option 1) Start server manually #### (option 1) Start server manually
bundle exec rails s -e production bundle exec rails s -e production
@@ -123,9 +115,26 @@ Contributing
### Gems ### Gems
Apart from database adapter, install development and testing gems: Install development and testing gems, including at least MySQL and SQLite
database adapters:
bundle config --local with mysql development test bundle config --local with development test mysql sqlite
#### Using gems from local git repos
When working on gem PRs, it may be useful to work with proposed changes locally
until PRs get accepted upstream. To add gem hosted in local git repository:
bundle config set local.capybara /var/www/tmp/capybara
Add gem to `Gemfile`:
gem "capybara", git: "https://github.com/teamcapybara/capybara", branch: "branch-name"
and install:
bundle install
bundle show capybara
### Configuration ### Configuration
@@ -138,20 +147,29 @@ assets.
### Database ### Database
Grant database user privileges for development and test environments, Grant database user privileges for development and test environments. Example
possibly with different Ruby versions: below shows how to grant privileges to all databases which names start with
`fixinme-` on MySQL:
> mysql -p > mysql -p
mysql> create user `fixinme-dev`@localhost identified by '<some password>'; mysql> create user `fixinme-dev`@localhost identified by '<some password>';
mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost; mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost;
mysql> flush privileges; mysql> flush privileges;
Dumping development data before database reset:
mysqldump -h <address> -u <user> -p --no-create-info --no-tablespaces --complete-insert <database> > tmp/data.sql
### Development environment ### Development environment
Starting application server in development environment: Starting application server in development environment:
bundle exec rails s -e development bundle exec rails s -e development
Accessing database console when more than one test db is present:
bundle exec rails dbconsole -e test --db sqlite3
For running rake tasks, prepend command with environment: For running rake tasks, prepend command with environment:
RAILS_ENV=development bundle exec rails ... RAILS_ENV=development bundle exec rails ...
@@ -164,14 +182,22 @@ Tests need to be run from within toplevel application directory:
bundle exec rails test:system bundle exec rails test:system
* system test(s) with seed/test name specified: * system test(s) with seed or test name specified:
bundle exec rails test:system --seed 64690 --name test_add_unit bundle exec rails test:system --include test_add_unit --seed 64690
* all tests from one file, with optional seed: * all tests from one file, optionally with seed:
bundle exec rails test test/system/users_test.rb --seed 1234 bundle exec rails test test/system/users_test.rb --seed 1234
* system tests for selected database configuration (if multiple present):
bundle exec rails test:system --include /^test_sqlite3_/
* single system test for all database configurations (if multiple present):
bundle exec rails test:system --include /^test_\\w+_add_unit$/
### Icons ### Icons
Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/ Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 432 B

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 432 B

Before

Width:  |  Height:  |  Size: 524 B

After

Width:  |  Height:  |  Size: 524 B

Before

Width:  |  Height:  |  Size: 618 B

After

Width:  |  Height:  |  Size: 618 B

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 152 B

Before

Width:  |  Height:  |  Size: 151 B

After

Width:  |  Height:  |  Size: 151 B

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

Before

Width:  |  Height:  |  Size: 603 B

After

Width:  |  Height:  |  Size: 603 B

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 278 B

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 231 B

Before

Width:  |  Height:  |  Size: 148 B

After

Width:  |  Height:  |  Size: 148 B

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 382 B

Before

Width:  |  Height:  |  Size: 330 B

After

Width:  |  Height:  |  Size: 330 B

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 350 B

Before

Width:  |  Height:  |  Size: 189 B

After

Width:  |  Height:  |  Size: 189 B

Before

Width:  |  Height:  |  Size: 192 B

After

Width:  |  Height:  |  Size: 192 B

Before

Width:  |  Height:  |  Size: 174 B

After

Width:  |  Height:  |  Size: 174 B

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 597 B

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 771 B

After

Width:  |  Height:  |  Size: 771 B

Before

Width:  |  Height:  |  Size: 671 B

After

Width:  |  Height:  |  Size: 671 B

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 220 B

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

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

Before

Width:  |  Height:  |  Size: 135 B

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

Before

Width:  |  Height:  |  Size: 135 B

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

Before

Width:  |  Height:  |  Size: 197 B

After

Width:  |  Height:  |  Size: 197 B

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

Before

Width:  |  Height:  |  Size: 423 B

After

Width:  |  Height:  |  Size: 423 B

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 173 B

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 475 B

+31 -80
View File
@@ -36,7 +36,6 @@
--color-blue: #009ade; --color-blue: #009ade;
--color-dark-red: #b21237; --color-dark-red: #b21237;
--color-red: #ff1f5b; --color-red: #ff1f5b;
--color-purple: #8b2be2;
--depth: 0; --depth: 0;
@@ -58,6 +57,11 @@
} }
/* NOTE: move to higher priority layer instead of using !important?; add CSS /* NOTE: move to higher priority layer instead of using !important?; add CSS
* @layer requirements in README */ * @layer requirements in README */
/* Disable elements with:
* `[aria-disabled=true]` - non-form elements (e.g. links),
* `[disabled]` - form controls, that may be enabled conditionally,
* `[readonly]` - form controls, that stay disabled for form lifetime. */
[aria-disabled=true],
[disabled] { [disabled] {
border-color: var(--color-border-gray) !important; border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important; color: var(--color-border-gray) !important;
@@ -111,11 +115,6 @@ svg {
svg:last-child { svg:last-child {
margin-right: 0; margin-right: 0;
} }
.chart-panel svg {
height: auto;
margin: 0;
width: auto;
}
textarea { textarea {
margin: 0; margin: 0;
} }
@@ -205,6 +204,10 @@ textarea:invalid {
border-color: var(--color-red); border-color: var(--color-red);
outline-color: var(--color-red); outline-color: var(--color-red);
} }
pre {
display: inline;
font: inherit;
}
/* `.button`: button-styled <a>, <button>, <input type=submit>. /* `.button`: button-styled <a>, <button>, <input type=submit>.
* `.link`: any other <a>. * `.link`: any other <a>.
@@ -237,16 +240,17 @@ textarea:invalid {
text-decoration: underline 1px var(--color-border-gray); text-decoration: underline 1px var(--color-border-gray);
text-underline-offset: 0.25em; text-underline-offset: 0.25em;
} }
button.link {
border: none;
padding: 0;
}
[name=cancel],
.auxiliary { .auxiliary {
border-color: var(--color-border-gray); border-color: var(--color-nav-gray);
color: var(--color-nav-gray); color: var(--color-nav-gray);
fill: var(--color-nav-gray); fill: var(--color-nav-gray);
} }
table .button {
border-color: var(--color-border-gray);
font-weight: normal;
height: 100%;
padding: 0.4em;
}
.button:focus-visible, .button:focus-visible,
.tab:focus-visible, .tab:focus-visible,
.tab:hover { .tab:hover {
@@ -262,13 +266,6 @@ button.link {
background-color: var(--color-red); background-color: var(--color-red);
border-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 { .link:focus-visible {
text-decoration-color: var(--color-gray); text-decoration-color: var(--color-gray);
} }
@@ -276,13 +273,6 @@ tr:has(select[data-changed]) button[name="button"],
color: var(--color-blue); color: var(--color-blue);
text-decoration-color: var(--color-blue); text-decoration-color: var(--color-blue);
} }
table .button {
border-color: var(--color-border-gray);
color: var(--color-table-gray);
font-weight: normal;
height: 100%;
padding: 0.4em;
}
/* NOTE: collapse gaps around empty rows (`topside`) once possible with /* NOTE: collapse gaps around empty rows (`topside`) once possible with
@@ -377,21 +367,21 @@ header {
line-height: 2.2em; line-height: 2.2em;
pointer-events: auto; pointer-events: auto;
} }
.flash:before { .flash::before {
filter: invert(100%); filter: invert(1);
height: 1.4em; height: 1.4em;
margin: 0 0.5em; margin: 0 0.5em;
width: 1.4em; width: 1.4em;
} }
.flash.alert:before { .flash.alert::before {
content: url('pictograms/alert-outline.svg'); content: url('alert-outline.svg');
} }
.flash.alert { .flash.alert {
border-color: var(--color-red); border-color: var(--color-red);
background-color: var(--color-red); background-color: var(--color-red);
} }
.flash.notice:before { .flash.notice::before {
content: url('pictograms/check-circle-outline.svg'); content: url('check-circle-outline.svg');
} }
.flash.notice { .flash.notice {
border-color: var(--color-blue); border-color: var(--color-blue);
@@ -454,15 +444,18 @@ header {
.tabular-form table { .tabular-form table {
border: none; border: none;
border-spacing: 0.4em 0; border-spacing: 0;
margin-inline: -0.4em;
} }
.tabular-form table td { .tabular-form table td {
border: none; border: none;
padding-inline-start: 0.4em;
vertical-align: middle; vertical-align: middle;
} }
.tabular-form table td { .tabular-form table td:first-child {
padding-inline: 0; padding-inline-start: 0;
}
.tabular-form table td:last-child {
padding-inline-end: 0;
} }
.tabular-form table :is(form, input, select, textarea):only-child { .tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0; margin-inline-start: 0;
@@ -530,7 +523,7 @@ header {
cursor: grab; cursor: grab;
} }
.items-table .form td { .items-table .form td {
vertical-align: middle; vertical-align: top;
} }
.items-table td:not(:first-child), .items-table td:not(:first-child),
.grayed { .grayed {
@@ -595,7 +588,7 @@ summary::before {
background-color: currentColor; background-color: currentColor;
content: ""; content: "";
height: 1em; height: 1em;
mask-image: url('pictograms/chevron-down.svg'); mask-image: url('chevron-down.svg');
mask-size: cover; mask-size: cover;
width: 1em; width: 1em;
} }
@@ -657,45 +650,3 @@ li::marker {
min-width: 66%; min-width: 66%;
width: max-content; 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;
}
+3 -3
View File
@@ -55,9 +55,9 @@ class ApplicationController < ActionController::Base
private private
def render_no_content(record) def render_errors(record)
helpers.render_errors(record) target = record.new_record? ? [:new, record.parent || record.class] : [:edit, record]
render html: nil, layout: true render 'errors', assigns: {record: record, target: target}
end end
def rescue_turbo(exception) def rescue_turbo(exception)
-12
View File
@@ -1,12 +0,0 @@
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
+3 -3
View File
@@ -17,7 +17,7 @@ class Default::UnitsController < ApplicationController
def import def import
@unit.port!(current_user) @unit.port!(current_user)
flash.now[:notice] = t('.success', unit: @unit) flash.now.notice = t('.success', unit: @unit)
ensure ensure
run_and_render :index run_and_render :index
end end
@@ -30,14 +30,14 @@ class Default::UnitsController < ApplicationController
def export def export
@unit.port!(nil) @unit.port!(nil)
flash.now[:notice] = t('.success', unit: @unit) flash.now.notice = t('.success', unit: @unit)
ensure ensure
run_and_render :index run_and_render :index
end end
def destroy def destroy
@unit.destroy! @unit.destroy!
flash.now[:notice] = t('.success', unit: @unit) flash.now.notice = t('.success', unit: @unit)
ensure ensure
run_and_render :index run_and_render :index
end end
+2 -48
View File
@@ -1,12 +1,7 @@
class MeasurementsController < ApplicationController 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 def index
load_measurements @measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
end end
def new def new
@@ -14,49 +9,8 @@ class MeasurementsController < ApplicationController
end end
def create 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 end
def destroy 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
end end
+9 -10
View File
@@ -8,10 +8,6 @@ class QuantitiesController < ApplicationController
raise AccessForbidden unless current_user.at_least(:active) raise AccessForbidden unless current_user.at_least(:active)
end end
before_action only: [:new, :edit, :create, :update] do
@user_units = current_user.units.ordered
end
def index def index
@quantities = current_user.quantities.ordered.includes(:parent, :subquantities) @quantities = current_user.quantities.ordered.includes(:parent, :subquantities)
end end
@@ -25,7 +21,7 @@ class QuantitiesController < ApplicationController
if @quantity.save if @quantity.save
@before = @quantity.successive @before = @quantity.successive
@ancestors = @quantity.ancestors @ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity) flash.now.notice = t('.success', quantity: @quantity)
else else
render :new render :new
end end
@@ -37,7 +33,7 @@ class QuantitiesController < ApplicationController
def update def update
if @quantity.update(quantity_params.except(:parent_id)) if @quantity.update(quantity_params.except(:parent_id))
@ancestors = @quantity.ancestors @ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity) flash.now.notice = t('.success', quantity: @quantity)
else else
render :edit render :edit
end end
@@ -47,18 +43,21 @@ class QuantitiesController < ApplicationController
permitted = params.require(:quantity).permit(:parent_id) permitted = params.require(:quantity).permit(:parent_id)
@previous_ancestors = @quantity.ancestors @previous_ancestors = @quantity.ancestors
# Until UI blocks all disallowed reparents, render error messages if present if @quantity.update(permitted)
render_no_content(@quantity) unless @quantity.update(permitted)
@ancestors = @quantity.ancestors @ancestors = @quantity.ancestors
@self_and_progenies = @quantity.with_progenies @self_and_progenies = @quantity.with_progenies
@before = @self_and_progenies.last.successive @before = @self_and_progenies.last.successive
else
# Until UI blocks all disallowed reparents, render error messages if present.
flash.now.alert = @quantity.errors.full_messages
render :nothing
end
end end
def destroy def destroy
@quantity.destroy! @quantity.destroy!
@ancestors = @quantity.ancestors @ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity) flash.now.notice = t('.success', quantity: @quantity)
end end
private private
+1 -1
View File
@@ -6,7 +6,7 @@ class ReadoutsController < ApplicationController
def new def new
@quantities -= @prev_quantities @quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty? # TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} }) @readouts = @quantities.map { |q| q.readouts.build }
@user_units = current_user.units.ordered @user_units = current_user.units.ordered
+15 -24
View File
@@ -1,10 +1,9 @@
class UnitsController < ApplicationController class UnitsController < ApplicationController
before_action only: :new do before_action ->{ @unit = current_user.units.find_by!(id: params[:id]) },
find_unit if params[:id].present? only: [:new, :edit, :update, :rebase, :destroy],
end unless: ->{ action_name == "new" && params[:id].nil? }
before_action :find_unit, only: [:edit, :update, :rebase, :destroy]
before_action except: :index do before_action except: [:index] do
raise AccessForbidden unless current_user.at_least(:active) raise AccessForbidden unless current_user.at_least(:active)
end end
@@ -17,12 +16,12 @@ class UnitsController < ApplicationController
end end
def create def create
@unit = current_user.units.new(unit_params) @unit = current_user.units.new(params.expect(Unit::ATTRIBUTES))
if @unit.save if @unit.save
@before = @unit.successive @before = @unit.successive
flash.now[:notice] = t('.success', unit: @unit) flash.now.notice = t('.success', unit: @unit)
else else
render :new render_errors @unit
end end
end end
@@ -30,40 +29,32 @@ class UnitsController < ApplicationController
end end
def update def update
if @unit.update(unit_params.except(:base_id)) if @unit.update(params.except(:base_id).expect(Unit::ATTRIBUTES))
flash.now[:notice] = t('.success', unit: @unit) flash.now.notice = t('.success', unit: @unit)
else else
render :edit render_errors @unit
end end
end end
# TODO: Avoid double table width change by first un-hiding table header, # TODO: Avoid double table width change by first un-hiding table header,
# then displaying index, e.g. by re-displaying header in index # then displaying index, e.g. by re-displaying header in index
def rebase def rebase
permitted = params.require(:unit).permit(:base_id) unit_params = params.expect(unit: :base_id)
permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1 unit_params.merge!(multiplier: 1.0) if unit_params[:base_id].blank?
@previous_base = @unit.base @previous_base = @unit.base
@unit.update!(permitted) @unit.update!(unit_params)
@before = @unit.successive @before = @unit.successive
if @unit.multiplier_previously_changed? if @unit.multiplier_previously_changed?
flash.now[:notice] = t(".multiplier_reset", unit: @unit) flash.now.notice = t(".multiplier_reset", unit: @unit)
end end
end end
def destroy def destroy
@unit.destroy! @unit.destroy!
flash.now[:notice] = t('.success', unit: @unit) flash.now.notice = t('.success', unit: @unit)
end end
private private
def unit_params
params.require(:unit).permit(Unit::ATTRIBUTES)
end
def find_unit
@unit = current_user.units.find_by!(id: params[:id])
end
end end
+17 -41
View File
@@ -40,7 +40,7 @@ module ApplicationHelper
# controller.action.* => helpers.label.model.* => activerecord.attributes.model.* # controller.action.* => helpers.label.model.* => activerecord.attributes.model.*
# First 2 levels are translated recursively. # First 2 levels are translated recursively.
label(method, class: classes) do |builder| label(method, class: classes) do |builder|
translation = I18n.config.with(**handler) { deep_translate(method, **options) } translation = I18n.with(**handler) { deep_translate(method, **options) }
translation.presence || "#{builder.translation}:" translation.presence || "#{builder.translation}:"
end end
end end
@@ -86,34 +86,26 @@ module ApplicationHelper
def initialize(...) def initialize(...)
super(...) super(...)
@default_options.merge!(@options.slice(:form)) @default_options.merge!(@options.slice(:form))
@default_html_options.merge!(@options.slice(:form))
end end
[:text_field, :password_field, :text_area].each do |selector| [:text_field, :password_field, :text_area].each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {}) def #{selector}(method, options = {})
options[:maxlength] ||= object.class.type_for_attribute(method).limit options[:maxlength] ||= object.type_for_attribute(method).limit
if object.errors.include?(method)
options[:pattern] = except_pattern(object.public_send(method), options[:pattern])
end
super super
end end
RUBY_EVAL RUBY_EVAL
end end
def number_field(method, options = {}) def number_field(method, options = {})
attr_type = object.type_for_attribute(method) case object.type_for_attribute(method).type
case attr_type.type when :float, :double
when :decimal options[:min] ||= Float::MIN_15.to_scientific
options[:value] = object.public_send(method)&.to_scientific options[:max] ||= Float::MAX_15.to_scientific
options[:step] ||= BigDecimal(10).power(-attr_type.scale) options[:oninput] = 'numberValidate(event)'
options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) -
options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max]
options[:size] ||= attr_type.precision / 2
when :float
options[:size] ||= 6 options[:size] ||= 6
options[:step] ||= :any
options[:value] = object.public_send("#{method}_before_type_cast")
end end
super super
end end
@@ -132,11 +124,7 @@ module ApplicationHelper
def submit_default_value def submit_default_value
svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : '' svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : ''
@template.svg_tag("pictograms/#{svg_name}", super) @template.svg_tag(svg_name, super)
end
def except_pattern(value, pattern = nil)
"(?!^#{Regexp.escape(value)}$)" + (pattern || ".*")
end end
end end
@@ -147,8 +135,6 @@ module ApplicationHelper
record_object, options = nil, record_object if record_object.is_a?(Hash) record_object, options = nil, record_object if record_object.is_a?(Hash)
extra_options = {builder: TabularFormBuilder, skip_default_ids: true} extra_options = {builder: TabularFormBuilder, skip_default_ids: true}
options = merge_attributes(options, extra_options) options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block) fields_for(record_name, record_object, **options, &block)
end end
@@ -161,7 +147,7 @@ module ApplicationHelper
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
label, options = nil, label if label.is_a? Hash label, options = nil, label if label.is_a? Hash
svg_tag = tag.svg(**options) do svg_tag = tag.svg(**options) do
tag.use(href: "#{image_path(source + ".svg")}#icon") tag.use(href: image_path("#{source}.svg") + '#icon')
end end
label.blank? ? svg_tag : svg_tag + tag.span(label) label.blank? ? svg_tag : svg_tag + tag.span(label)
end end
@@ -171,14 +157,13 @@ module ApplicationHelper
['measurements', 'scale-bathroom', :restricted], ['measurements', 'scale-bathroom', :restricted],
['quantities', 'axis-arrow', :restricted, 'right'], ['quantities', 'axis-arrow', :restricted, 'right'],
['units', 'weight-gram', :restricted], ['units', 'weight-gram', :restricted],
['charts', 'chart-line', :restricted],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin? # TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin], ['users', 'account-multiple-outline', :admin],
] ]
menu_tabs.map do |name, image, status, css_class| menu_tabs.map do |name, image, status, css_class|
if current_user.at_least(status) if current_user.at_least(status)
link_to svg_tag("pictograms/#{image}", t("#{name}.navigation")), link_to svg_tag(image, t("#{name}.navigation")),
{controller: "/#{name}", action: "index"}, {controller: "/#{name}", action: "index"},
class: class_names('tab', css_class, active: name == current_tab) class: class_names('tab', css_class, active: name == current_tab)
end end
@@ -210,28 +195,19 @@ module ApplicationHelper
def image_link_to_unless_current(name, image = nil, options = nil, html_options = {}) def image_link_to_unless_current(name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:link, name, image, html_options) name, html_options = link_or_button_options(:link, name, image, html_options)
# NOTE: Starting from Rails 8.1.0, below condition can be replaced with: if current_page?(options, method: [:get, :post])
# current_page?(options, method: [:get, :post])
if request.path == url_for(options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES html_options = html_options.deep_merge DISABLED_ATTRIBUTES
end end
link_to name, options, html_options link_to name, options, html_options
end end
def render_errors(records) def render_flash
# Conversion of flash to Array only required because of Devise
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end
def render_flash_messages
flash.map do |entry, messages| flash.map do |entry, messages|
# Conversion of flash to Array only required because of Devise # Conversion of flash to Array only required because of Devise.
Array(messages).map do |message| Array(messages).map do |message|
tag.div class: "flash #{entry}" do tag.div class: "flash #{entry}" do
tag.span(sanitize(message)) + tag.span(sanitize(message)) +
svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"}) svg_tag('close-outline', {onclick: "this.parentElement.remove()"})
end end
end end
end.join.html_safe end.join.html_safe
@@ -263,7 +239,7 @@ module ApplicationHelper
link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream) link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream)
name = name.to_s name = name.to_s
name += '...' if type == :link && !link_is_local name += '...' if type == :link && !link_is_local
name = svg_tag("pictograms/#{image}", name) if image name = svg_tag(image, name) if image
[name, html_options] [name, html_options]
end end
+244 -13
View File
@@ -1,28 +1,112 @@
// Configure your import map in config/importmap.rb. Read more: // Configure your import map in config/importmap.rb. Read more:
// https://github.com/rails/importmap-rails // https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails" import "@hotwired/turbo-rails"
import "controllers" // TODO: access `data-` attributes through
import { disableElement, enableElement } from "element_helpers" // `keyname in`/`dataset.keyname`/`delete dataset.keyname`
/* Hide page before loaded for testing purposes */ // Show page if hidden for testing purposes.
function showPage() { function showPage(event) {
document.documentElement.style.visibility="visible" document.documentElement.style.visibility="visible"
} }
document.addEventListener('turbo:load', showPage) document.addEventListener('turbo:load', showPage)
/* Turbo stream actions */ // <details> event handlers.
Turbo.StreamElement.prototype.disableElement = disableElement function detailsChange(event) {
Turbo.StreamElement.prototype.enableElement = enableElement var target = event.currentTarget
Turbo.StreamActions.disable = function() { var count = target.querySelectorAll('input:checked:not([disabled])').length
this.targetElements.forEach(disableElement) 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)
} }
Turbo.StreamActions.enable = function() { }
this.targetElements.forEach(enableElement) 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}))
});
// Validation of multiple-submit forms with different field requirements.
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
// Pattern for number precision verification. Should accept all number formats
// accepted by Kernel#Float. Lookahead checks the number of significant digits
// in mantissa, following expression makes sure there is only one dot.
const numberRegExp = /^\-?(?=(?:\.?0)*(?:\.?\d){1,15}?(?:\.?0)*\.?(?:[eE]|$))(?<mantissa>\d*\.?\d+|\d+\.)(?:[eE]\-?\d{1,3})?$/
// Validation of input[type=number] value precision.
function numberValidate(event) {
var input = event.target
input.setCustomValidity("")
if (!input.validity.valid) return
var match = numberRegExp.exec(input.value)
if (!match ||
(parseFloat(input.value) == 0.0 && match.groups['mantissa'].match(/[1-9]/))) {
// TODO: move message to locales
input.setCustomValidity("Please select a value within range of double precision floating-point format.")
}
}
window.numberValidate = numberValidate
// Turbo stream actions.
const FORM_CONTROLS = ["BUTTON", "FIELDSET", "INPUT", "OPTGROUP", "OPTION", "SELECT",
"TEXTAREA"]
Turbo.StreamElement.prototype.disableElement = function(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.setAttribute("disabled", "disabled")
} else {
element.setAttribute("aria-disabled", "true")
// NOTE: tabindex should not be disabled?
element.setAttribute("tabindex", "-1")
}
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => { this.disableElement(e) })
} }
/* TODO: change to visibility = collapse to avoid width change? */ Turbo.StreamElement.prototype.enableElement = function(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.removeAttribute("disabled")
} else {
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() { Turbo.StreamActions.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" }) this.targetElements.forEach((e) => { e.style.display = "none" })
} }
@@ -39,7 +123,7 @@ Turbo.StreamActions.collapse = function() {
Turbo.StreamActions.close_form = function() { Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => { this.targetElements.forEach((e) => {
/* Move focus if there's no focus or focus inside form being closed */ // Move focus if there's no focus or focus inside form being closed.
const focused = document.activeElement const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) { if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`) let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
@@ -63,3 +147,150 @@ Turbo.StreamActions.unselect = function() {
this.enableElement(e) this.enableElement(e)
}) })
} }
Turbo.StreamActions.validate = function() {
this.targetElements.forEach((form) => {
this.templateContent.querySelectorAll("span").forEach((span) => {
var control = document.querySelector(
`[form="${form.id}"][name="${span.dataset.name}"]`
)
control.setCustomValidity(span.textContent)
// Add event listener in capture phase to clear custom validity before
// `[oninput]` or any other bubble phase handler is invoked.
// https://www.quirksmode.org/js/events_order.html
// TODO: keep invalidating current value whenever user re-enters it. Store
// invalid value and validation message in `[data-invalid-message/value]`
// and check input value on "input" event (below) (using `[pattern]` may not
// be suitable due to inability to customize validtion message; also
// pattern may be used for other, conflicting purposes).
// After user executes any other action (while form is open), remove `[data-]`
// attributes and custom validation, as value may no longer be invalid.
control.addEventListener(
"input",
function(event) { event.target.setCustomValidity("") },
{capture: true, once: true}
)
})
form.reportValidity()
})
}
// Keyboard event processing handlers.
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.
* TODO: replace Leave table with Enter document?
* https://www.quirksmode.org/js/events_order.html#link4
*/
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
@@ -1,7 +0,0 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
@@ -1,45 +0,0 @@
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 })
}
}
@@ -1,56 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { disableElement, enableElement } from "element_helpers"
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'
enableElement(this.submitButtonTarget)
} else {
this.countLabelTarget.textContent = this.countLabelTarget.dataset.prompt
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
}
}
}
}
@@ -1,62 +0,0 @@
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()
}
}
@@ -1,25 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
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()
}
}
}
-3
View File
@@ -1,3 +0,0 @@
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
@@ -1,29 +0,0 @@
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
})
}
}
@@ -1,17 +0,0 @@
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'
}
}
@@ -1,37 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { disableElement, enableElement } from "element_helpers"
export default class extends Controller {
static targets = ["select", "button"]
unitChanged() {
if (this.selectTarget.value && this.selectTarget.value !== this.selectTarget.dataset.defaultUnitId) {
enableElement(this.buttonTarget)
} else {
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()
}
}
-11
View File
@@ -1,11 +0,0 @@
export function disableElement(element) {
element.setAttribute("disabled", "disabled")
element.setAttribute("aria-disabled", "true")
element.setAttribute("tabindex", "-1")
}
export function enableElement(element) {
element.removeAttribute("disabled")
element.removeAttribute("aria-disabled")
element.removeAttribute("tabindex")
}
+2
View File
@@ -1,4 +1,6 @@
class ApplicationRecord < ActiveRecord::Base class ApplicationRecord < ActiveRecord::Base
include ActionView::Helpers::TagHelper
class << self class << self
# Cached attribute has non-user assignable value calculated from other # Cached attribute has non-user assignable value calculated from other
# attributes' values on create/update. This simplifies and speeds up # attributes' values on create/update. This simplifies and speeds up
+3
View File
@@ -0,0 +1,3 @@
class Note < ApplicationRecord
ATTRIBUTES = [:text]
end
+4 -3
View File
@@ -1,18 +1,19 @@
class Quantity < ApplicationRecord class Quantity < ApplicationRecord
ATTRIBUTES = [:name, :description, :parent_id, :default_unit_id] ATTRIBUTES = [:name, :description, :parent_id]
attr_cached :depth, :pathname attr_cached :depth, :pathname
belongs_to :user, optional: true belongs_to :user, optional: true
belongs_to :parent, optional: true, class_name: "Quantity" 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", has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error inverse_of: :parent, dependent: :restrict_with_error
has_many :readouts, dependent: :restrict_with_error
validate if: ->{ parent.present? } do validate if: ->{ parent.present? } do
errors.add(:parent, :user_mismatch) unless user_id == parent.user_id errors.add(:parent, :user_mismatch) unless user_id == parent.user_id
errors.add(:parent, :self_reference) if id == parent_id errors.add(:parent, :self_reference) if id == parent_id
end end
validate if: ->{ parent.present? }, on: :update do validate if: ->{ parent.present? }, on: :update do
# TODO: should not report error for (parent == self)
errors.add(:parent, :descendant_reference) if ancestor_of?(parent) errors.add(:parent, :descendant_reference) if ancestor_of?(parent)
end end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]}, validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
@@ -98,7 +99,7 @@ class Quantity < ApplicationRecord
} }
def to_s def to_s
name tag.pre name
end end
def to_s_with_depth def to_s_with_depth
+3 -1
View File
@@ -1,7 +1,9 @@
class Readout < ApplicationRecord class Readout < ApplicationRecord
ATTRIBUTES = [:quantity_id, :value, :unit_id, :taken_at] ATTRIBUTES = [:quantity_id, :value, :unit_id]
belongs_to :user belongs_to :user
belongs_to :quantity belongs_to :quantity
belongs_to :unit belongs_to :unit
# TODO: validate quantity.user_id == unit.user_id != NULL
end end
+19 -20
View File
@@ -1,8 +1,9 @@
class Unit < ApplicationRecord class Unit < ApplicationRecord
ATTRIBUTES = [:symbol, :description, :multiplier, :base_id] ATTRIBUTES = {unit: [:symbol, :description, :multiplier, :base_id]}
belongs_to :user, optional: true belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit" belongs_to :base, optional: true, class_name: "Unit"
alias_method :parent, :base
has_many :subunits, class_name: "Unit", inverse_of: :base, has_many :subunits, class_name: "Unit", inverse_of: :base,
dependent: :restrict_with_error dependent: :restrict_with_error
@@ -14,8 +15,8 @@ class Unit < ApplicationRecord
validates :symbol, presence: true, uniqueness: {scope: :user_id}, validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit} length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit} validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base validates :multiplier, numericality: {equal_to: 1.0}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base validates :multiplier, numericality: {greater_than: 0.0}, if: :base
scope :defaults, ->{ where(user: nil) } scope :defaults, ->{ where(user: nil) }
scope :defaults_diff, ->{ scope :defaults_diff, ->{
@@ -77,15 +78,13 @@ class Unit < ApplicationRecord
.from(units).group(:base_id, :symbol) .from(units).group(:base_id, :symbol)
} }
scope :ordered, ->{ scope :ordered, ->{
left_outer_joins(:base).order(ordering) left_outer_joins(:base).order([
} arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
def self.ordering
[arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil), arel_table[:base_id].not_eq(nil),
:multiplier, arel_table[:multiplier],
:symbol] arel_table[:symbol]
end ])
}
before_destroy do before_destroy do
# TODO: disallow destruction if any object depends on this unit # TODO: disallow destruction if any object depends on this unit
@@ -93,7 +92,7 @@ class Unit < ApplicationRecord
end end
def to_s def to_s
symbol tag.pre symbol
end end
def movable? def movable?
@@ -104,21 +103,21 @@ class Unit < ApplicationRecord
user_id.nil? user_id.nil?
end end
# Should only by invoked on Units returned from #defaults_diff which are #portable # Should only by invoked on Units returned from #defaults_diff which are #portable.
def port!(recipient) def port!(recipient)
recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient) recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient)
params = slice(ATTRIBUTES - [:symbol, :base_id]) params = slice(ATTRIBUTES[:unit] - [:symbol, :base_id])
Unit.find_or_initialize_by(user: recipient, symbol: symbol) Unit.find_or_initialize_by(user: recipient, symbol: symbol)
.update!(base: recipient_base, **params) .update!(base: recipient_base, **params)
end end
def successive def successive
units = Unit.arel_table units = Unit.arel_table
lead = Arel::Nodes::NamedFunction.new('LAG', [units[:id]]) Unit.with(units_with_lag: user.units.left_outer_joins(:base).select(
window = Arel::Nodes::Window.new.order(*Unit.ordering) units[Arel.star],
lag_id = lead.over(window).as('lag_id') Arel::Nodes::NamedFunction.new('LAG', [units[:id]])
Unit.with( .over(Arel::Nodes::Window.new.order(Unit.ordered.order_values)).as('lag_id')
units: user.units.left_outer_joins(:base).select(units[Arel.star], lag_id) )).from(Arel::Table.new(:units_with_lag).as(:units))
).where(units[:lag_id].eq(id)).first .where(units[:lag_id].eq(id)).take
end end
end end
@@ -0,0 +1,7 @@
<%= turbo_stream.validate dom_target(*@target, :form) do %>
<% @record.errors.attribute_names.map do |attr| %>
<%= tag.span data: {name: field_name(dom_class(@record), attr)} do %>
<%- @record.errors.full_messages_for(attr).map { |m| m + '.' }.join(' ') %>
<% end %>
<% end %>
<% end %>
-4
View File
@@ -1,4 +0,0 @@
<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>
+1 -2
View File
@@ -14,7 +14,6 @@
<%= csp_meta_tag %> <%= csp_meta_tag %>
<%= stylesheet_link_tag "spreadsheet" %> <%= stylesheet_link_tag "spreadsheet" %>
<script src="https://cdn.plot.ly/plotly-basic-2.35.2.min.js"></script>
<%= javascript_importmap_tags %> <%= javascript_importmap_tags %>
<%#= turbo_page_requires_reload_tag %> <%#= turbo_page_requires_reload_tag %>
@@ -45,7 +44,7 @@
</header> </header>
<div id="flashes"> <div id="flashes">
<%= render_flash_messages %> <%= render_flash %>
</div> </div>
<%# Allows overwriting/clearing navigation menu for some views %> <%# Allows overwriting/clearing navigation menu for some views %>
@@ -1,6 +1,6 @@
<%= yield %> <%# Render unconditionally, to clear previous flash messages. %>
<%# Some views may convert ActiveRecord errors to flashes, render at the end. %>
<%= turbo_stream.update :flashes do %> <%= turbo_stream.update :flashes do %>
<%= render_flash_messages %> <%= render_flash %>
<% end %> <% end %>
<%= yield %>
@@ -1,25 +0,0 @@
<%= 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 %>
@@ -1,2 +0,0 @@
<%= turbo_stream.close_form row %>
<%= turbo_stream.update :flashes %>
@@ -1,34 +0,0 @@
<% 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 %>
+14 -8
View File
@@ -1,6 +1,6 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form, <%= tabular_form_with model: Measurement.new, id: :measurement_form,
class: 'topside-area flex vertical center', class: 'topside-area flex vertical center',
html: {data: {controller: 'form', action: 'keydown->form#processKey'}} do |form| %> html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<table class="items-table center"> <table class="items-table center">
<tbody id="readouts"> <tbody id="readouts">
@@ -8,7 +8,7 @@
<tr class="italic"> <tr class="italic">
<td class="hexpand hmin50"><%= t '.taken_at_html' %></td> <td class="hexpand hmin50"><%= t '.taken_at_html' %></td>
<td colspan="3" class="ralign"> <td colspan="3" class="ralign">
<%= form.datetime_field :taken_at, required: true, value: Time.current.strftime('%Y-%m-%dT%H:%M') %> <%= form.datetime_field :taken_at, required: true %>
</td> </td>
</tr> </tr>
<% end %> <% end %>
@@ -17,23 +17,29 @@
<%# TODO: right-click selection; unnecessary with hierarchical tags? %> <%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open <details id="quantity_select" class="center hexpand" open
data-controller="details" onkeydown="detailsProcessKey(event)">
data-action="focusout->details#close change->details#change keydown->details#processKey">
<summary autofocus> <summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication --> <!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>" data-details-target="countLabel"> <span data-prompt="<%= t('.select_quantity') %>">
<%= t('.select_quantity') %> <%= t('.select_quantity') %>
</span> </span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true, <%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true, formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true, details_target: 'submitButton'} %> data: {turbo_stream: true} %>
</summary> </summary>
<ul data-details-target="list"><%= quantities_check_boxes(@quantities) %></ul> <ul><%= quantities_check_boxes(@quantities) %></ul>
</details> </details>
<div class="flex reverse"> <div class="flex reverse">
<%= form.button id: :create_measurement_button, disabled: true -%> <%= form.button id: :create_measurement_button, disabled: true -%>
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel, <%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %> class: 'auxiliary dangerous', onclick: render_turbo_stream('form_close') %>
</div> </div>
<% end %> <% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>
-22
View File
@@ -1,22 +0,0 @@
<%= 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', onclick: 'this.blur();', 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 %>
@@ -1,41 +0,0 @@
<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>
@@ -1,13 +0,0 @@
<% 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 %>
@@ -1,5 +0,0 @@
<%= 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} %>
@@ -1,18 +0,0 @@
<% 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 %>
+4 -31
View File
@@ -1,41 +1,14 @@
<%# TODO: show hint when no quantities/units defined %> <%# TODO: show hint when no quantities/units defined %>
<div class="rightside-area buttongrid" data-controller="measurements-view"> <div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path, <%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
id: :new_measurement_link, onclick: 'this.blur();', id: :new_measurement_link, onclick: 'this.blur();',
data: {turbo_stream: true} %> data: {turbo_stream: true} %>
<% end %> <% 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> </div>
<div class="main-area measurements-section"> <table class="main-area">
<%= tag.div id: :measurement_edit_form %> <tbody id="measurements">
<table class="items-table measurements-compact" data-controller="measurements"> <%= render(@measurements) || render_no_items %>
<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> </tbody>
</table> </table>
<div id="measurements-wide" class="measurements-wide">
<%= render 'wide_table', wide_groups: @wide_groups, wide_quantities: @wide_quantities %>
</div>
</div>
@@ -1,4 +0,0 @@
<%= 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} %>
+2 -8
View File
@@ -1,7 +1,6 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %> <%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {controller: 'form', action: 'keydown->form#processKey', data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>"> <td style="--depth:<%= @quantity.depth %>">
<%= form.text_field :name, required: true, autofocus: true, size: 20 %> <%= form.text_field :name, required: true, autofocus: true, size: 20 %>
@@ -9,11 +8,6 @@
<td> <td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %> <%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td> </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"> <td class="flex">
<%= form.button %> <%= form.button %>
+5 -7
View File
@@ -1,17 +1,15 @@
<%= tag.tr id: dom_id(quantity), <%= tag.tr id: dom_id(quantity),
draggable: true, ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
data: {controller: 'drag', ondragover: "dragOver(event)", ondrop: "drop(event)",
action: 'dragstart->drag#start dragend->drag#end dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave', ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
drag_drag_path_value: reparent_quantity_path(quantity), data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drag_drop_id_value: dom_id(quantity), drop_id_param: "quantity[parent_id]"} do %>
drag_drop_id_param_value: 'quantity[parent_id]'} do %>
<td style="--depth:<%= quantity.depth %>"> <td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link', <%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %> onclick: 'this.blur();', data: {turbo_stream: true} %>
</td> </td>
<td><%= quantity.description %></td> <td><%= quantity.description %></td>
<td><%= quantity.default_unit&.symbol %></td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="flex"> <td class="flex">
+4 -6
View File
@@ -16,18 +16,16 @@
<tr> <tr>
<th><%= Quantity.human_attribute_name(:name) %></th> <th><%= Quantity.human_attribute_name(:name) %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th> <th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th>
<th><%= Quantity.human_attribute_name(:default_unit) %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t :actions %></th> <th><%= t :actions %></th>
<th></th> <th></th>
<% end %> <% end %>
</tr> </tr>
<%= tag.tr id: "quantity_", hidden: true, <%= tag.tr id: "quantity_", hidden: true,
data: {controller: 'drag', ondragover: "dragOver(event)", ondrop: "drop(event)",
action: 'dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave', ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
drag_drop_id_value: 'quantity_', data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
drag_drop_id_param_value: 'quantity[parent_id]'} do %> <th colspan="4"><%= t '.top_level_drop' %></th>
<th colspan="5"><%= t '.top_level_drop' %></th>
<% end %> <% end %>
</thead> </thead>
<tbody id="quantities"> <tbody id="quantities">
+3 -12
View File
@@ -1,6 +1,6 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %> <%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout), <%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
data: {controller: 'readout-unit'} do %>
<td> <td>
<%# TODO: add grayed readout index (in separate column?) %> <%# TODO: add grayed readout index (in separate column?) %>
<%= readout.quantity.relative_pathname(@superquantity) %> <%= readout.quantity.relative_pathname(@superquantity) %>
@@ -12,19 +12,10 @@
<td> <td>
<%= form.collection_select :unit_id, @user_units, :id, <%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) }, ->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: '', disabled: '', selected: readout.quantity.default_unit_id || ''}, required: true, {prompt: '', disabled: '', selected: ''}, required: true %>
data: {default_unit_id: readout.quantity.default_unit_id || '',
readout_unit_target: 'select',
action: 'change->readout-unit#unitChanged'} %>
</td> </td>
<td class="flex"> <td class="flex">
<%# TODO: change to _link_ after giving up displaying relative paths %> <%# 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, <%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity), formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %> formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
+3 -4
View File
@@ -1,7 +1,6 @@
<%= tabular_fields_for @unit, form: form_tag do |form| %> <%= tabular_fields_for @unit, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {controller: 'form', action: 'keydown->form#processKey', data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @unit.base_id? ? 1 : 0 %>"> <td style="--depth:<%= @unit.base_id? ? 1 : 0 %>">
<%= form.text_field :symbol, required: true, autofocus: true, size: 12 %> <%= form.text_field :symbol, required: true, autofocus: true, size: 12 %>
@@ -10,7 +9,7 @@
<%= form.text_area :description, cols: 30, rows: 1, escape: false %> <%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td> </td>
<td> <td>
<%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %> <%= form.number_field :multiplier, required: true if @unit.base_id? %>
</td> </td>
<td class="flex"> <td class="flex">
+7 -7
View File
@@ -1,17 +1,17 @@
<%= tag.tr id: dom_id(unit), <%= tag.tr id: dom_id(unit),
draggable: true, ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
data: {controller: 'drag', ondragover: "dragOver(event)", ondrop: "drop(event)",
action: 'dragstart->drag#start dragend->drag#end dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave', ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
drag_drag_path_value: rebase_unit_path(unit), data: {drag_path: rebase_unit_path(unit),
drag_drop_id_value: dom_id(unit.base || unit), drop_id: dom_id(unit.base || unit),
drag_drop_id_param_value: 'unit[base_id]'} do %> drop_id_param: "unit[base_id]"} do %>
<td style="--depth:<%= unit.base_id? ? 1 : 0 %>"> <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', onclick: 'this.blur();',
data: {turbo_stream: true} %> data: {turbo_stream: true} %>
</td> </td>
<td><%= unit.description %></td> <td><%= unit.description %></td>
<td class="ralign"><%= unit.multiplier.to_html %></td> <td class="ralign"><%= unit.multiplier.to_html if unit.base_id? %></td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="flex"> <td class="flex">
+1 -1
View File
@@ -1,4 +1,4 @@
<%= turbo_stream.close_form dom_id(@unit.base || Unit, :new) %> <%= turbo_stream.close_form dom_target(:new, @unit.base || :unit) %>
<%= turbo_stream.remove :no_items %> <%= turbo_stream.remove :no_items %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %> <%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %> <%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
+3 -3
View File
@@ -1,7 +1,7 @@
<% ids = {row: dom_id(@unit, :edit), <% ids = {row: dom_target(:edit, @unit),
hidden_row: dom_id(@unit), hidden_row: dom_target(@unit),
link: nil, link: nil,
form_tag: dom_id(@unit, :edit, :form)} %> form_tag: dom_target(:edit, @unit, :form)} %>
<%= turbo_stream.append :unit_form do %> <%= turbo_stream.append :unit_form do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do %> <%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do %>
+3 -4
View File
@@ -22,10 +22,9 @@
<% end %> <% end %>
</tr> </tr>
<%= tag.tr id: "unit_", hidden: true, <%= tag.tr id: "unit_", hidden: true,
data: {controller: 'drag', ondragover: "dragOver(event)", ondrop: "drop(event)",
action: 'dragover->drag#over drop->drag#drop dragenter->drag#enter dragleave->drag#leave', ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
drag_drop_id_value: 'unit_', data: {drop_id: "unit_", drop_id_param: "unit[base_id]"} do %>
drag_drop_id_param_value: 'unit[base_id]'} do %>
<th colspan="5"><%= t '.top_level_drop' %></th> <th colspan="5"><%= t '.top_level_drop' %></th>
<% end %> <% end %>
</thead> </thead>
+4 -4
View File
@@ -1,8 +1,8 @@
<% dom_obj = @unit.base || @unit %> <% target_id = dom_target(:new, @unit.base || :unit) %>
<% ids = {row: dom_id(dom_obj, :new), <% ids = {row: target_id,
hidden_row: nil, hidden_row: nil,
link: dom_id(dom_obj, :new, :link), link: dom_target(target_id, :link),
form_tag: dom_id(dom_obj, :new, :form)} %> form_tag: dom_target(target_id, :form)} %>
<%= turbo_stream.disable ids[:link] -%> <%= turbo_stream.disable ids[:link] -%>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= turbo_stream.remove @unit %> <%= turbo_stream.remove @unit %>
<%= turbo_stream.replace @previous_base if @previous_base %> <%= turbo_stream.replace @previous_base if @previous_base %>
<%= turbo_stream.replace @unit.base if @unit.base_id? && (@previous_base.id != @unit.base_id) %> <%= turbo_stream.replace @unit.base if @unit.base_id? && (@previous_base&.id != @unit.base_id) %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %> <%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
+1 -1
View File
@@ -1,3 +1,3 @@
<%= turbo_stream.close_form dom_id(@unit, :edit) %> <%= turbo_stream.close_form dom_target(:edit, @unit) %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %> <%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= turbo_stream.replace @unit %> <%= turbo_stream.replace @unit %>
+3 -5
View File
@@ -4,7 +4,7 @@
<th><%= User.human_attribute_name(:email) %></th> <th><%= User.human_attribute_name(:email) %></th>
<th><%= User.human_attribute_name(:status) %></th> <th><%= User.human_attribute_name(:status) %></th>
<th><%= User.human_attribute_name(:confirmed_at) %></th> <th><%= User.human_attribute_name(:confirmed_at) %></th>
<th><%= User.human_attribute_name(:created_at) %>&nbsp;<sup>(UTC)</sup></th> <th><pre><%= User.human_attribute_name(:created_at) %> <sup>(UTC)</sup></pre></th>
<th><%= t :actions %></th> <th><%= t :actions %></th>
</tr> </tr>
</thead> </thead>
@@ -22,10 +22,8 @@
<% end %> <% end %>
<% end %> <% end %>
</td> </td>
<td> <td><%= svg_tag 'checkbox-marked-outline' if user.confirmed_at.present? %></td>
<%= svg_tag 'pictograms/checkbox-marked-outline' if user.confirmed_at.present? %> <td><pre><%= l user.created_at, format: :without_tz %></pre></td>
</td>
<td><%= l user.created_at, format: :without_tz %></td>
<td class="flex"> <td class="flex">
<% if allow_disguise?(user) %> <% if allow_disguise?(user) %>
<%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %> <%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %>
@@ -1,2 +1 @@
<%# For some reason flash messages are duplicated in bot flash and flash.now %>
<% flash.discard %> <% flash.discard %>
+1 -1
View File
@@ -1,5 +1,5 @@
<% content_for :navigation, flush: true do %> <% content_for :navigation, flush: true do %>
<%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)), <%= link_to svg_tag('arrow-left-bold-outline', t(:back)),
request.referer.present? ? :back : root_path, class: 'tab' %> request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %> <% end %>
+1 -1
View File
@@ -1,5 +1,5 @@
<%= labeled_form_for resource, url: user_registration_path, <%= labeled_form_for resource, url: user_registration_path,
html: {class: 'main-area', data: {controller: 'form', action: 'submit->form#validate'}} do |f| %> html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, <%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %> autocomplete: 'email' %>
+1 -1
View File
@@ -1,5 +1,5 @@
<%= labeled_form_for resource, url: user_session_path, <%= labeled_form_for resource, url: user_session_path,
html: {class: 'main-area', data: {controller: 'form', action: 'submit->form#validate'}} do |f| %> html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, <%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %> autocomplete: 'email' %>
+1 -2
View File
@@ -1,6 +1,5 @@
<% content_for :navigation, flush: true do %> <% content_for :navigation, flush: true do %>
<%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)), users_path, <%= link_to svg_tag('arrow-left-bold-outline', t(:back)), users_path, class: 'tab' %>
class: 'tab' %>
<% end %> <% end %>
<%= labeled_form_for @user, html: {class: 'main-area'} do |f| %> <%= labeled_form_for @user, html: {class: 'main-area'} do |f| %>
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require "active_support/continuous_integration"
CI = ActiveSupport::ContinuousIntegration
require_relative "../config/ci.rb"

Some files were not shown because too many files have changed in this diff Show More