Compare commits

..

1 Commits

Author SHA1 Message Date
46dd480b4e Alternative new Measurement form, WIP 2025-08-15 23:26:57 +02:00
104 changed files with 998 additions and 2344 deletions

122
CLAUDE.md
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

View File

@@ -1,34 +0,0 @@
DESIGN
======
Below is a list of design decisions. The justification is to be consulted
whenever a change is considered, to avoid regressions.
### Data type for DB storage of numeric values (`decimal` vs `float`)
* among database engines supported (by Rails), SQLite offers storage of
`decimal` data type with the lowest precision, equal to the precision of
`REAL` type (double precision float value, IEEE 754), but in a floating point
format,
* decimal types in other database engines offer greater precision, but store
data in a fixed point format,
* biology-related values differ by several orders of magnitude; storing them in
fixed point format would only make sense if required precision would be
greater than that offered by floating point format,
* even then, fixed point would mean either bigger memory requirements or
worse precision for numbers close to scale limit,
* for a fixed point format to use the same 8 bytes of storage as IEEE
754, precision would need to be limited to 18 digits (4 bytes/9 digits)
and scale approximately half of that - 9,
* double precision floating point guarantees 15 digits of precision, which
is more than enough for all expected use cases,
* single precision floating point only guarntees 6 digits of precision,
which is estimated to be too low for some use cases (e.g. storing
latitude/longitude with a resolution grater than 100m)
* double precision floating point (IEEE 754) is a standard that ensures
compatibility with all database engines,
* the same data format is used internally by Ruby as a `Float`; it
guarantees no conversions between storage and computation,
* as a standard with hardware implementations ensures both: computing
efficiency and hardware/3rd party library compatibility as opposed to Ruby
custom `BigDecimal` type

10
Gemfile
View File

@@ -1,7 +1,7 @@
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", "~> 7.2.2"
gem "sprockets-rails" gem "sprockets-rails"
gem "puma", "~> 6.0" gem "puma", "~> 6.0"
gem "sassc-rails" gem "sassc-rails"
@@ -25,7 +25,6 @@ gem "devise"
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,4 @@ 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

View File

@@ -1,68 +1,66 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.2.3) actioncable (7.2.2.1)
actionpack (= 7.2.3) actionpack (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
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 (7.2.2.1)
actionpack (= 7.2.3) actionpack (= 7.2.2.1)
activejob (= 7.2.3) activejob (= 7.2.2.1)
activerecord (= 7.2.3) activerecord (= 7.2.2.1)
activestorage (= 7.2.3) activestorage (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (7.2.3) actionmailer (7.2.2.1)
actionpack (= 7.2.3) actionpack (= 7.2.2.1)
actionview (= 7.2.3) actionview (= 7.2.2.1)
activejob (= 7.2.3) activejob (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.2.3) actionpack (7.2.2.1)
actionview (= 7.2.3) actionview (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
cgi
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4, < 3.3) rack (>= 2.2.4, < 3.2)
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 (7.2.2.1)
actionpack (= 7.2.3) actionpack (= 7.2.2.1)
activerecord (= 7.2.3) activerecord (= 7.2.2.1)
activestorage (= 7.2.3) activestorage (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.2.3) actionview (7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
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 (7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.2.3) activemodel (7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
activerecord (7.2.3) activerecord (7.2.2.1)
activemodel (= 7.2.3) activemodel (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.2.3) activestorage (7.2.2.1)
actionpack (= 7.2.3) actionpack (= 7.2.2.1)
activejob (= 7.2.3) activejob (= 7.2.2.1)
activerecord (= 7.2.3) activerecord (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.2.3) activesupport (7.2.2.1)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@@ -74,16 +72,15 @@ GEM
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) addressable (2.8.7)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 7.0)
base64 (0.3.0) base64 (0.2.0)
bcrypt (3.1.21) bcrypt (3.1.20)
benchmark (0.5.0) benchmark (0.4.0)
bigdecimal (4.0.1) bigdecimal (3.1.9)
bindex (0.8.1) bindex (0.8.1)
builder (3.3.0) builder (3.3.0)
byebug (13.0.0) byebug (12.0.0)
reline (>= 0.6.0)
capybara (3.40.0) capybara (3.40.0)
addressable addressable
matrix matrix
@@ -93,59 +90,54 @@ 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.5)
concurrent-ruby (1.3.6) connection_pool (2.5.2)
connection_pool (3.0.2)
crass (1.0.6) crass (1.0.6)
date (3.5.1) date (3.4.1)
devise (5.0.2) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 7.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
drb (2.2.3) drb (2.2.1)
erb (6.0.2)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl) ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu) ffi (1.17.2-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl) ffi (1.17.2-arm-linux-musl)
ffi (1.17.3-arm64-darwin) ffi (1.17.2-arm64-darwin)
ffi (1.17.3-x86_64-darwin) ffi (1.17.2-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl) ffi (1.17.2-x86_64-linux-musl)
globalid (1.3.0) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.8) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (2.2.3) importmap-rails (2.1.0)
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.8.0)
irb (1.17.0) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
logger (1.7.0) logger (1.7.0)
loofah (2.25.0) loofah (2.24.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.8.1)
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.0.4)
matrix (0.4.3) matrix (0.4.2)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.27.0) minitest (5.25.5)
mysql2 (0.5.7) mysql2 (0.5.6)
bigdecimal net-imap (0.5.7)
net-imap (0.6.3)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -154,94 +146,83 @@ GEM
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.4)
nokogiri (1.19.1-aarch64-linux-gnu) nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl) nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-gnu) nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-musl) nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin) nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin) nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu) nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl) nokogiri (1.18.8-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.5.9)
pg (1.6.3-aarch64-linux) pp (0.6.2)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.9.0) psych (5.2.3)
psych (5.3.1)
date date
stringio stringio
public_suffix (7.0.2) public_suffix (6.0.1)
puma (6.6.1) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.5) rack (3.1.13)
rack-session (2.1.1) rack-session (2.1.0)
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.2.1)
rack (>= 3) rack (>= 3)
rails (7.2.3) rails (7.2.2.1)
actioncable (= 7.2.3) actioncable (= 7.2.2.1)
actionmailbox (= 7.2.3) actionmailbox (= 7.2.2.1)
actionmailer (= 7.2.3) actionmailer (= 7.2.2.1)
actionpack (= 7.2.3) actionpack (= 7.2.2.1)
actiontext (= 7.2.3) actiontext (= 7.2.2.1)
actionview (= 7.2.3) actionview (= 7.2.2.1)
activejob (= 7.2.3) activejob (= 7.2.2.1)
activemodel (= 7.2.3) activemodel (= 7.2.2.1)
activerecord (= 7.2.3) activerecord (= 7.2.2.1)
activestorage (= 7.2.3) activestorage (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.2.3) railties (= 7.2.2.1)
rails-dom-testing (2.3.0) rails-dom-testing (2.2.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.6.2)
loofah (~> 2.25) loofah (~> 2.21)
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 (7.2.2.1)
actionpack (= 7.2.3) actionpack (= 7.2.2.1)
activesupport (= 7.2.3) activesupport (= 7.2.2.1)
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)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rake (13.3.1) rake (13.2.1)
rdoc (7.2.0) rdoc (6.13.1)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort regexp_parser (2.10.0)
regexp_parser (2.11.3) reline (0.6.1)
reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.2.0) responders (3.1.1)
actionpack (>= 7.0) actionpack (>= 5.2)
railties (>= 7.0) railties (>= 5.2)
rexml (3.4.4) rexml (3.4.1)
rubyzip (3.2.2) rubyzip (2.4.1)
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,11 +232,11 @@ GEM
sprockets-rails sprockets-rails
tilt tilt
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.41.0) selenium-webdriver (4.31.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
sprockets (4.2.2) sprockets (4.2.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -265,22 +246,19 @@ GEM
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.7.3-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl) sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu) sqlite3 (2.7.3-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl) sqlite3 (2.7.3-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin) sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.9.0-x86_64-darwin) sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl) sqlite3 (2.7.3-x86_64-linux-musl)
stimulus-rails (1.3.4) stringio (3.1.7)
railties (>= 6.0.0) thor (1.3.2)
stringio (3.2.0) tilt (2.6.0)
thor (1.5.0) timeout (0.4.3)
tilt (2.7.0) turbo-rails (2.0.13)
timeout (0.6.0)
tsort (0.2.0)
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)
@@ -294,13 +272,13 @@ GEM
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.7.7)
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.7.2)
PLATFORMS PLATFORMS
aarch64-linux-gnu aarch64-linux-gnu
@@ -317,16 +295,14 @@ DEPENDENCIES
capybara capybara
devise devise
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 (~> 7.2.2)
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

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" /></svg> <svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" /></svg>

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 167 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z" /></svg>

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 293 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" /></svg>

Before

Width:  |  Height:  |  Size: 148 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M14.06,9L15,9.94L5.92,19H5V18.08L14.06,9M17.66,3C17.41,3 17.15,3.1 16.96,3.29L15.13,5.12L18.88,8.87L20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18.17,3.09 17.92,3 17.66,3M14.06,6.19L3,17.25V21H6.75L17.81,9.94L14.06,6.19Z" /></svg>

Before

Width:  |  Height:  |  Size: 316 B

View File

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

View File

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

View File

@@ -15,15 +15,6 @@
*= require_self *= require_self
*/ */
/* Strive for simplicity:
* * style elements/tags only - if possible,
* * replace element/tag name with class name - if element has to be styled
* differently depending on context (e.g. <form>, <table>, <a> as link/button),
* * styles with multiple selectors should have all selectors with same
* specificity, to allow proper rule specificity vs order management.
*
* NOTE: style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */
:root { :root {
--color-focus-gray: #f3f3f3; --color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd; --color-border-gray: #dddddd;
@@ -36,7 +27,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;
@@ -56,168 +46,50 @@
:focus-visible { :focus-visible {
outline: none; outline: none;
} }
/* NOTE: move to higher priority layer instead of using !important?; add CSS
* @layer requirements in README */
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
/* NOTE: cannot set cursor when `pointer-events: none`; can be fixed by setting
* `cursor` on wrapping element.
cursor: not-allowed; */
fill: var(--color-border-gray) !important;
pointer-events: none !important;
}
/* Styles set `display` without distinguishing between [hidden] elements, making
* them visible. */
[hidden] {
display: none !important;
}
/* Color coding of input controls' background: /* TODO: collapse gaps around empty rows (`topside`) once possible
* blue - target for interaction with pointer, * https://github.com/w3c/csswg-drafts/issues/5813 */
* gray - target for interaction with keyboard, body {
* red - destructive, non-undoable action. display: grid;
*/ gap: 0.8em;
/* TODO: merge selectors using :is() */ grid-template-areas:
a, "header header header"
"nav nav nav"
"leftempty topside rightempty"
"leftside main rightside";
grid-template-columns: 1fr auto 1fr;
grid-template-rows: repeat(4, auto);
font-family: system-ui;
margin: 0.4em;
}
button, button,
details,
input, input,
select, select,
summary,
textarea { textarea {
background-color: inherit; background-color: inherit;
font: inherit; font: inherit;
} }
details,
input, input,
select { select {
text-align: inherit; text-align: inherit;
} }
input,
select,
summary,
textarea {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
padding: 0.2em 0.4em;
}
svg {
height: 1.4em;
margin: 0 0.2em 0 0;
width: 1.4em;
}
svg:last-child {
margin-right: 0;
}
.chart-panel svg {
height: auto;
margin: 0;
width: auto;
}
textarea {
margin: 0;
}
input[type=checkbox] {
accent-color: var(--color-blue);
appearance: none;
-webkit-appearance: none;
display: flex;
height: 1.1em;
margin: 0;
padding: 0;
width: 1.1em;
}
input[type=checkbox]:checked {
appearance: checkbox;
-webkit-appearance: checkbox;
}
/* Hide spin buttons of <input type=number>. */
/* TODO: add spin buttons inside <input type=number>: before (-) and after (+) input. */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
text-align: end;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Text color of table form controls:
* - black for row/table forms,
* - inherited for internal (column specific) buttons/forms. */
table input,
table select,
table summary,
table textarea {
border-color: var(--color-border-gray);
}
table input,
table select,
table textarea {
padding-block: 0.375em;
}
table form input,
table form select,
table form summary,
table form textarea {
color: inherit;
}
table svg:not(:only-child) {
height: 1.25em;
width: 1.25em;
}
input:focus-visible,
select:focus-visible,
select:focus-within,
/* TODO: how to achieve `summary:focus-within` for `::details-content`? */
summary:focus-visible,
textarea:focus-visible {
accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray);
color: black;
}
input:hover,
select:hover,
summary:hover,
textarea:hover {
border-color: var(--color-blue);
outline: 1px solid var(--color-blue);
}
select:hover,
summary:hover {
color: black;
cursor: pointer;
}
/* TODO: style <details>/<summary> focus to match <select> as much as possible.
summary:focus-visible::before,
summary:hover::before {
background-color: black;
}
*/
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline-color: var(--color-red);
}
/* `.button`: button-styled <a>, <button>, <input type=submit>.
* `.link`: any other <a>. /* blue - target for interaction with pointer */
* `.tab`: tab-styled <a>. /* gray - target for interaction with keyboard */
*/ /* TODO: remove non-font-size rems from buttons/inputs below */
.button, a,
.link, button,
.tab { input[type=submit] {
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
} }
/* [hidden] submit controls cannot have `display` set as it makes them visible */
.button, .button,
button:not([hidden]),
input[type=submit]:not([hidden]),
.tab { .tab {
align-items: center; align-items: center;
color: var(--color-gray); color: var(--color-gray);
@@ -225,34 +97,66 @@ textarea:invalid {
fill: var(--color-gray); fill: var(--color-gray);
font-weight: bold; font-weight: bold;
} }
.button { .button,
border: 1px solid var(--color-gray); button,
border-radius: 0.25em; input[type=submit] {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.6em 0.5em; padding: 0.4em;
width: fit-content; width: fit-content;
} }
.link { input:not([type=submit]):not([type=checkbox]),
color: inherit; select,
text-decoration: underline 1px var(--color-border-gray); textarea {
text-underline-offset: 0.25em; padding: 0.2em 0.4em;
} }
button.link { .button,
border: none; button,
padding: 0; input,
select,
textarea {
border: solid 1px var(--color-gray);
border-radius: 0.25em;
} }
[name=cancel], fieldset,
.auxiliary { textarea {
border-color: var(--color-border-gray); margin: 0
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
} }
.button > svg,
.tab > svg,
button > svg {
height: 1.8em;
width: 1.8em;
}
.button > svg:not(:last-child),
.tab > svg:not(:last-child),
button > svg:not(:last-child) {
padding-right: 0.4em;
}
fieldset {
padding: 0.4em;
}
legend {
color: var(--color-gray);
display: flex;
gap: 0.4em;
width: 100%;
}
legend span {
align-content: center;
flex-grow: 1;
}
/* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from table.items - as the style should be
* same everywhere */
.button:focus-visible, .button:focus-visible,
.tab:focus-visible, button:focus-visible,
.tab:hover { input[type=submit]:focus-visible {
background-color: var(--color-focus-gray); background-color: var(--color-focus-gray);
} }
.button:hover { .button:hover,
button:hover,
input[type=submit]:hover {
background-color: var(--color-blue); background-color: var(--color-blue);
border-color: var(--color-blue); border-color: var(--color-blue);
color: white; color: white;
@@ -262,55 +166,68 @@ 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]) { input[type=checkbox] {
background-color: var(--color-purple); accent-color: var(--color-blue);
border-color: var(--color-purple); appearance: none;
color: white; -webkit-appearance: none;
fill: white; display: flex;
height: 1.1rem;
margin: 0;
width: 1.1rem;
} }
.link:focus-visible { input[type=checkbox]:checked {
text-decoration-color: var(--color-gray); appearance: checkbox;
-webkit-appearance: checkbox;
} }
.link:hover { /* Hide spin buttons in input number fields */
color: var(--color-blue); input[type=number] {
text-decoration-color: var(--color-blue); appearance: textfield;
-moz-appearance: textfield;
} }
table .button { input::-webkit-inner-spin-button {
border-color: var(--color-border-gray); -webkit-appearance: none;
color: var(--color-table-gray); }
font-weight: normal; input::-webkit-outer-spin-button {
height: 100%; -webkit-appearance: none;
padding: 0.4em; margin: 0;
}
input:hover,
select:hover,
textarea:hover {
border-color: var(--color-blue);
outline: solid 1px var(--color-blue);
}
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline: solid 1px var(--color-red);
}
select:hover {
cursor: pointer;
}
input:focus-visible,
select:focus-within,
select:focus-visible,
textarea:focus-visible {
accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray);
}
fieldset,
input[type=text]:read-only,
textarea:read-only {
border: none;
padding-left: 0;
padding-right: 0;
} }
/* NOTE: collapse gaps around empty rows (`topside`) once possible with
* `grid-collapse` property and remove alternative `grid-template-areas`.
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
font-family: system-ui;
margin: 0.4em;
}
body:has(> .topside-area) {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
}
header { header {
grid-area: header; grid-area: header;
} }
.navigation { .navigation {
display: flex; display: flex;
grid-area: nav; grid-area: nav;
@@ -319,31 +236,37 @@ header {
margin-inline-start: 4%; margin-inline-start: 4%;
} }
.navigation > .tab { .navigation > .tab {
border-bottom: 2px solid var(--color-nav-gray); border-bottom: solid 2px var(--color-nav-gray);
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
justify-content: center; justify-content: center;
padding-block: 0.4em; padding-block: 0.3em;
}
.navigation > .tab:hover,
.navigation > .tab:focus-visible {
background-color: var(--color-focus-gray);
} }
.navigation > .tab.active { .navigation > .tab.active {
border-bottom: 4px solid var(--color-blue); border-bottom: solid 4px var(--color-blue);
color: var(--color-blue); color: var(--color-blue);
fill: var(--color-blue); fill: var(--color-blue);
} }
.topside-area {
.topside {
grid-area: topside; grid-area: topside;
} }
.leftside-area { .leftside {
grid-area: leftside; grid-area: leftside;
} }
.main-area { .main {
grid-area: main; grid-area: main;
} }
.rightside-area { .rightside {
grid-area: rightside; grid-area: rightside;
} }
.buttongrid { .buttongrid {
display: grid; display: grid;
gap: 0.4em; gap: 0.4em;
@@ -351,14 +274,14 @@ header {
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
grid-template-rows: max-content; grid-template-rows: max-content;
} }
.tools-area { .tools {
grid-area: tools; grid-area: tools;
} }
#flashes { #flashes {
display: grid; display: grid;
row-gap: 0.4em; gap: 0.2em;
grid-template-columns: 1fr auto auto auto 1fr; grid-template-columns: 1fr auto auto auto 1fr;
left: 0; left: 0;
pointer-events: none; pointer-events: none;
@@ -374,17 +297,13 @@ header {
display: grid; display: grid;
grid-column: 2/5; grid-column: 2/5;
grid-template-columns: subgrid; grid-template-columns: subgrid;
line-height: 2.2em;
pointer-events: auto; pointer-events: auto;
} }
.flash:before {
filter: invert(100%);
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
}
.flash.alert:before { .flash.alert:before {
content: url('pictograms/alert-outline.svg'); content: url('pictograms/alert-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
} }
.flash.alert { .flash.alert {
border-color: var(--color-red); border-color: var(--color-red);
@@ -392,310 +311,267 @@ header {
} }
.flash.notice:before { .flash.notice:before {
content: url('pictograms/check-circle-outline.svg'); content: url('pictograms/check-circle-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
} }
.flash.notice { .flash.notice {
border-color: var(--color-blue); border-color: var(--color-blue);
background-color: var(--color-blue); background-color: var(--color-blue);
} }
.flash svg { .flash > div {
cursor: pointer; grid-column: 2;
fill: white;
height: 2.2em;
opacity: 0.6;
padding: 0.4em 0.5em;
width: 2.4em;
} }
.flash svg:hover { /* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
cursor: pointer;
font-size: 1.4em;
font-weight: bold;
grid-column: 3;
opacity: 0.6;
padding: 0.2em 0.4em;
}
.flash > button:hover {
opacity: 1; opacity: 1;
} }
.labeled-form { /* TODO: Update styling, including rem removal. */
align-items: center; form table {
display: grid; border-spacing: 0.8rem;
gap: 0.9em 1.1em;
grid-template-columns: 1fr minmax(max-content, 0.5fr) 1fr;
} }
.labeled-form label { form tr td:first-child {
color: var(--color-gray); color: var(--color-gray);
font-size: 0.9rem; font-size: 0.9rem;
grid-column: 1; padding-right: 0.25rem;
text-align: right; text-align: right;
white-space: nowrap;
} }
.labeled-form label.required { form label.required {
font-weight: bold; font-weight: bold;
} }
/* Don't style `label.error + input` if case already covered by `input:invalid`. */ form label.error,
.labeled-form label.error { form td.error::after {
color: var(--color-red); color: var(--color-red);
} }
.labeled-form em { form td.error {
display: -webkit-box;
}
form td.error::after {
content: attr(data-content);
font-size: 0.9rem;
margin-left: 1rem;
padding: 0.25rem 0;
position: absolute;
}
form em {
color: var(--color-text-gray); color: var(--color-text-gray);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: normal; font-weight: normal;
} }
.labeled-form input { form input[type=submit] {
grid-column: 2; float: none;
}
.labeled-form input[type=submit] {
font-size: 1rem; font-size: 1rem;
margin: 1em auto 0 auto; margin: 1.5rem auto 0 auto;
padding: 0.75em; padding: 0.75rem;
}
.labeled-form .auxiliary {
grid-column: 3;
/* If more buttons are needed, `grid-row` can be replaced with
* `reading-flow: grid-columns` to ensure proper [tabindex] order. */
grid-row: 1;
height: 100%;
padding-block: 0;
}
.tabular-form table {
border: none;
border-spacing: 0.4em 0;
margin-inline: -0.4em;
}
.tabular-form table td {
border: none;
vertical-align: middle;
}
.tabular-form table td {
padding-inline: 0;
}
.tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0;
} }
.items-table { table.items {
border-spacing: 0; border-spacing: 0;
border: 1px solid var(--color-border-gray); border: solid 1px var(--color-border-gray);
border-radius: 0.25em; border-radius: 0.25em;
font-size: 0.85rem; font-size: 0.85rem;
text-align: left; text-align: left;
} }
.items-table thead { table.items thead {
font-size: 0.8rem; font-size: 0.8rem;
} }
.items-table thead, table.items thead,
.items-table tbody tr:hover { table.items tbody tr:hover {
background-color: var(--color-focus-gray); background-color: var(--color-focus-gray);
} }
.items-table th { table.items th {
padding: 0.75em 0 0.75em 1em; padding-block: 0.75em;
text-align: center; text-align: center;
} }
.items-table th:last-child { table.items th,
padding-inline-end: 0.4em; table.items td {
padding-inline: 1em 0;
} }
.items-table td { /* For <a> to fill <td> completely, we use an ::after pseudoelement. */
border-top: 1px solid var(--color-border-gray); table.items td.link {
height: 2.4em; padding: 0;
padding: 0.1em 0 0.1em calc(1em + var(--depth) * 0.8em);
}
.items-table td:last-child {
padding-inline-end: 0.1em;
}
.items-table :is(form, input, select, textarea):only-child {
margin-inline-start: calc(-0.4em - 0.9px);
}
/* For <a> to fill table cell completely, we use an `::after` pseudoelement. */
/* TODO: expand to whole row? will require adjusting z-index on inputs/buttons */
.items-table td:has(> .link) {
position: relative; position: relative;
} }
.items-table .link::after { table.items td.link a {
color: inherit;
font: inherit;
}
table.items td.link a::after {
content: ''; content: '';
inset: -1px 0 0 0; inset: 0;
position: absolute; position: absolute;
} }
.items-table .flex { table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em);
}
table.items td:has(input, textarea) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items td:first-child:has(input, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
}
table.items th:last-child {
padding-inline-end: 0.4em;
}
table.items td:last-child {
padding-inline-end: 0.1em;
}
table.items td {
border-top: solid 1px var(--color-border-gray);
height: 2.4em;
padding-block: 0.1em;
}
table.items .actions {
display: flex;
gap: 0.4em; gap: 0.4em;
justify-content: end; justify-content: end;
} }
.items-table .dropzone { table.items .actions.centered {
justify-content: center;
}
table.items tr.dropzone {
position: relative; position: relative;
} }
.items-table .dropzone::after { table.items tr.dropzone::after {
content: ''; content: '';
inset: 1px 0 0 0; inset: 1px 0 0 0;
position: absolute; position: absolute;
outline: 2px dashed var(--color-blue); outline: dashed 2px var(--color-blue);
outline-offset: -1px; outline-offset: -1px;
z-index: var(--z-index-table-row-outline); z-index: var(--z-index-table-row-outline);
} }
.items-table .handle { table.items td.handle {
cursor: grab; cursor: move;
} }
.items-table .form td { table.items tr.form td {
vertical-align: middle; vertical-align: top;
} }
.items-table td:not(:first-child),
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update styling, including rem removal. */
table.items td.link a:hover,
table.items td.link a:focus-visible,
table.items td.link a:hover:focus-visible {
text-decoration: underline;
text-decoration-thickness: 0.05rem;
text-underline-offset: 0.2rem;
}
table.items td.link a:hover {
color: var(--color-blue);
}
table.items td.link a:focus-visible {
text-decoration-color: var(--color-gray);
}
table.items td.link a:hover:focus-visible {
color: var(--color-dark-blue);
}
table.items td:not(:first-child),
.grayed { .grayed {
color: var(--color-table-gray); color: var(--color-table-gray);
fill: var(--color-gray); fill: var(--color-table-gray);
} }
.items-table td:has(> svg:only-child) { table.items td.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.8rem;
padding: 1em;
}
table.items svg {
height: 1.2rem;
vertical-align: middle;
width: 1.2rem;
}
table.items td.svg {
text-align: center; text-align: center;
} }
table.items td.number {
text-align: right;
}
table.items .button,
table.items button,
table.items input[type=submit] {
font-weight: normal;
padding: 0.3em;
}
table.items input:not([type=submit]):not([type=checkbox]),
table.items select,
table.items textarea {
padding-block: 0.375em;
}
/* TODO: find a way (layers?) to style inputs differently while making sure
* hover works properly without using :not(:hover) selectors here. */
table.items .button:not(:hover),
table.items button:not(:hover),
table.items input:not(:hover),
table.items select:not(:hover),
table.items textarea:not(:hover) {
border-color: var(--color-border-gray);
}
table.items .button:not(:hover),
table.items button:not(:hover),
table.items input[type=submit]:not(:hover),
table.items select:not(:hover) {
color: var(--color-table-gray);
}
table.items select:focus-within,
table.items select:focus-visible {
color: black;
}
form a[name=cancel] {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
form table.items {
border: none;
}
form table.items td {
border: none;
text-align: left;
vertical-align: middle;
}
form table.items td:first-child {
color: inherit;
}
.center { .centered {
margin: 0 auto; margin: 0 auto;
} }
.hexpand { .extendedright {
width: 100%; margin-right: auto;
} }
.flex { .hflex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
} }
.flex.reverse { .vflex {
flex-direction: row-reverse; display: flex;
} gap: 0.8em;
.flex.vertical {
flex-direction: column; flex-direction: column;
} }
.hint { [disabled] {
color: var(--color-table-gray); border-color: var(--color-border-gray) !important;
font-style: italic; color: var(--color-border-gray) !important;
font-size: 0.9rem; cursor: not-allowed;
text-align: center; fill: var(--color-border-gray) !important;
pointer-events: none;
} }
.hmin50 { .unwrappable {
min-width: 50%;
}
.italic {
color: var(--color-gray);
font-style: italic;
}
.ralign {
text-align: right;
}
.rextend {
margin-right: auto;
}
details {
align-content: center;
position: relative;
}
summary {
align-items: center;
color: var(--color-gray);
display: flex;
gap: 0.4em;
height: 100%;
white-space: nowrap; white-space: nowrap;
} }
summary::before {
background-color: currentColor;
content: "";
height: 1em;
mask-image: url('pictograms/chevron-down.svg');
mask-size: cover;
width: 1em;
}
summary:has(.button) {
padding-block: 0;
padding-inline-end: 0;
}
summary .button {
border: 1px solid var(--color-border-gray);
border-radius: inherit;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-style: none none none solid;
height: 100%;
}
summary span {
width: 100%;
}
details[open] summary::before {
transform: scaleY(-1);
}
summary::marker {
padding-left: 0.25em;
}
/* NOTE: use `details[open]::details-content` once widely available. */
details[open] ul {
background-color: white;
border: 1px solid var(--color-border-gray);
border-radius: 0.25em;
box-shadow: 1px 1px 3px var(--color-border-gray);
margin: -1px 0 0 0;
padding-left: 0;
position: absolute;
width: 100%;
}
li:hover {
background-color: var(--color-focus-gray);
}
li label {
align-items: center;
display: flex;
line-height: 1.4em;
}
li input[type=checkbox] {
margin: 0 0.25em;
}
li::marker {
content: '';
}
/*
* TODO:
* * disable <label> containing disabled checkbox: `label:has(input[disabled])`,
* * disabled label styling,
* * focused label styling (currently only checkbox has focus),
* * disabled checkbox blue square focus removal.
* */
#measurement_form {
min-width: 66%;
width: max-content;
}
.measurements-section {
overflow-x: auto;
}
body[data-measurements-view=wide] .measurements-compact,
body[data-measurements-view=compact] .measurements-wide {
display: none;
}
body[data-measurements-view=compact] .view-toggle[data-view=compact],
body[data-measurements-view=wide] .view-toggle[data-view=wide] {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
fill: white;
}
.chart-panel {
width: 100%;
}
#measurements tr.grouped td {
border-top: none;
}
#measurements tr.grouped .taken-at,
#measurements tr.grouped .created-at {
visibility: hidden;
}
.measurements-wide td {
vertical-align: middle;
white-space: nowrap;
}
.wide-cell {
align-items: center;
display: inline-flex;
gap: 0.25em;
}
.wide-cell .button {
border: none;
font-size: inherit;
height: auto;
padding: 0;
}
.wide-cell button.link::after {
content: none;
}

View File

@@ -25,18 +25,6 @@ class ApplicationController < ActionController::Base
# Turbo will reload 2nd time with HTML format and flashes will be lost. # Turbo will reload 2nd time with HTML format and flashes will be lost.
rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo
# Required by #respond_with (gem `responders`) used by Devise controllers.
respond_to :html, :turbo_stream
def after_sign_in_path_for(resource)
# TODO: allow setting path per-user or save last path in session and restore
units_path
end
def after_sign_out_path_for(resource)
new_user_session_path
end
protected protected
def current_user_disguised? def current_user_disguised?

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

View File

@@ -1,12 +1,5 @@
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
end end
def new def new
@@ -14,49 +7,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

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

View File

@@ -1,33 +1,37 @@
class ReadoutsController < ApplicationController class ReadoutsController < ApplicationController
before_action :find_quantities, only: [:new] before_action :find_quantity, only: [:new, :discard]
before_action :find_quantity, only: [:discard]
before_action :find_prev_quantities, only: [:new, :discard] before_action :find_prev_quantities, only: [:new, :discard]
def new def new
@quantities -= @prev_quantities new_quantities =
# TODO: raise ParameterInvalid if new_quantities.empty? case params[:scope]
@readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} }) when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
new_quantities -= @prev_quantities
@readouts = current_user.readouts.build(new_quantities.map { |q| {quantity: q} })
@user_quantities = current_user.quantities.ordered
@user_units = current_user.units.ordered @user_units = current_user.units.ordered
quantities = @prev_quantities + @quantities @quantities = @prev_quantities + new_quantities
@superquantity = current_user.quantities # @common_ancestor = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first # .common_ancestors(all_quantities.map(&:parent_id)).first
end end
def discard def discard
@prev_quantities -= [@quantity] @prev_quantities -= [@quantity]
@superquantity = current_user.quantities @common_ancestor = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first .common_ancestors(@prev_quantities.map(&:parent_id)).first
end end
private private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id]) @quantity = current_user.quantities.find_by!(id: params[:id])
end end

View File

@@ -1,8 +1,5 @@
class User::ProfilesController < Devise::RegistrationsController class RegistrationsController < Devise::RegistrationsController
def destroy before_action :authenticate_user!, only: [:edit, :update, :destroy]
# TODO: Disallow/disable deletion for last admin account; update :edit view
super
end
protected protected

View File

@@ -37,7 +37,7 @@ class UsersController < ApplicationController
end end
# NOTE: limited actions availabe to :admin by design. Users are meant to # NOTE: limited actions availabe to :admin by design. Users are meant to
# manage their accounts by themselves through profiles. :admin # manage their accounts by themselves through registrations. :admin
# is allowed to sign-in (disguise) as user and make changes from there. # is allowed to sign-in (disguise) as user and make changes from there.
protected protected

View File

@@ -1,92 +1,65 @@
module ApplicationHelper module ApplicationHelper
class LabeledFormBuilder < ActionView::Helpers::FormBuilder # TODO: replace legacy content_tag with tag.tagname
(field_helpers - [:label, :hidden_field]).each do |selector| class LabelledFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [:label]).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 = {})
labeled_field_for(method, options) { super } labelled_row_for(method, options) { super }
end end
RUBY_EVAL RUBY_EVAL
end end
def select(method, choices = nil, options = {}, html_options = {}) def select(method, choices = nil, options = {}, html_options = {})
labeled_field_for(method, options) { super } labelled_row_for(method, options) { super }
end end
def submit(value = nil, options = {}) def submit(value, options = {})
value, options = nil, value if value.is_a?(Hash) @template.content_tag :tr do
options[:class] = @template.class_names('button', options[:class]) @template.content_tag :td, super, colspan: 2
super end
end
def form_for(&block)
@template.content_tag(:table, class: "centered") { yield(self) } +
# Display leftover error messages (there shouldn't be any)
@template.content_tag(:div, @object&.errors.full_messages.join(@template.tag(:br)))
end end
private private
def labeled_field_for(method, options) def labelled_row_for(method, options)
field = if options.delete(:readonly) then @template.content_tag :tr do
value = object.public_send(method) @template.content_tag(:td, label_for(method, options), class: "unwrappable") +
value = @template.l(value) if value.respond_to?(:strftime) @template.content_tag(:td, options.delete(:readonly) ? @object.public_send(method) : yield,
value ||= options[:placeholder] @object&.errors[method].present? ?
else {class: "error", data: {content: @object&.errors.delete(method).join(" and ")}} :
yield {})
end end
label_for(method, options) + field
end end
def label_for(method, options = {}) def label_for(method, options = {})
return '' if options[:label] == false
text = options.delete(:label)
text ||= @object.class.human_attribute_name(method).capitalize
classes = @template.class_names(required: options[:required], classes = @template.class_names(required: options[:required],
error: object.errors[method].present?) error: @object&.errors[method].present?)
label = label(method, "#{text}:", class: classes)
hint = options.delete(:hint)
handler = {missing_interpolation_argument_handler: method(:interpolation_missing)} label + (@template.tag(:br) + @template.content_tag(:em, hint) if hint)
# Label translation search order:
# controller.action.* => helpers.label.model.* => activerecord.attributes.model.*
# First 2 levels are translated recursively.
label(method, class: classes) do |builder|
translation = I18n.config.with(**handler) { deep_translate(method, **options) }
translation.presence || "#{builder.translation}:"
end end
end end
def interpolation_missing(key, values, string) def labelled_form_for(record, options = {}, &block)
@template.instance_values[key.to_s] || deep_translate(key, **values) options = options.deep_merge(builder: LabelledFormBuilder, data: {turbo: false})
end form_for(record, **options) { |f| f.form_for(&block) }
# Extension to label text translation:
# * recursive translation,
# * interpolation of (_relative_) translation key names and instance variables,
# * pluralization based on any interpolable value
# TODO: add unit tests for the above
def deep_translate(key, **options)
options[:default] = [
:".#{key}",
:"helpers.label.#{@object_name}.#{key}_html",
:"helpers.label.#{@object_name}.#{key}",
""
]
# At least 1 interpolation key is required for #translate to act on
# missing interpolation arguments (i.e. call custom handler).
# Also setting `key` to nil avoids recurrent translation.
options[key] = nil
@template.t(".#{key}_html", **options) do |translation, resolved_key|
if translation.is_a?(Array) && (count = translation.to_h[:count])
@template.t(resolved_key, count: I18n.interpolate(count, **options), **options)
else
translation
end
end
end
end
def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}}
form_for(record, **merge_attributes(options, extra_options), &block)
end end
class TabularFormBuilder < ActionView::Helpers::FormBuilder class TabularFormBuilder < ActionView::Helpers::FormBuilder
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|
@@ -102,29 +75,22 @@ module ApplicationHelper
end end
def number_field(method, options = {}) def number_field(method, options = {})
attr_type = object.type_for_attribute(method) value = object.public_send(method)
case attr_type.type if value.is_a?(BigDecimal)
when :decimal options[:value] = value.to_scientific
options[:value] = object.public_send(method)&.to_scientific type = object.class.type_for_attribute(method)
options[:step] ||= BigDecimal(10).power(-attr_type.scale) options[:step] ||= BigDecimal(10).power(-type.scale)
options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) - options[:max] ||= BigDecimal(10).power(type.precision - type.scale) -
options[:step] options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min] options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max] options[:min] ||= -options[:max]
options[:size] ||= attr_type.precision / 2
when :float
options[:size] ||= 6
end end
super super
end end
def button(value = nil, options = {}, &block) def button(value = nil, options = {}, &block)
# #button does not use #objectify_options/@default_options # button does not use #objectify_options
value, options = nil, value if value.is_a?(Hash) options.merge!(@options.slice(:form))
options = options.merge(
@default_options.slice(:form),
class: @template.class_names('button', options[:class])
)
super super
end end
@@ -145,22 +111,19 @@ module ApplicationHelper
# [autofocus]. Otherwise IDs are not unique when multiple forms are open # [autofocus]. Otherwise IDs are not unique when multiple forms are open
# and the first input gets focus. # and the first input gets focus.
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} options.merge!(builder: TabularFormBuilder, skip_default_ids: true)
options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name) render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block) fields_for(record_name, record_object, **options, &block)
end end
def tabular_form_with(**options, &block) def tabular_form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, class: 'tabular-form', options = options.deep_merge(builder: TabularFormBuilder,
html: {autocomplete: 'off'}} html: {autocomplete: 'off'})
form_with(**merge_attributes(options, extra_options), &block) form_with(**options, &block)
end end
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
label, options = nil, label if label.is_a? Hash svg_tag = content_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)
@@ -171,8 +134,6 @@ 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?
['users', 'account-multiple-outline', :admin], ['users', 'account-multiple-outline', :admin],
] ]
@@ -210,28 +171,21 @@ 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: html_options = html_options.deep_merge DISABLED_ATTRIBUTES if current_page?(options)
# current_page?(options, method: [:get, :post])
if request.path == url_for(options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES
end
link_to name, options, html_options link_to name, options, html_options
end end
def render_errors(records) def render_errors(records)
# Conversion of flash to Array only required because of Devise flash[:alert] ||= []
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages } Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end end
def render_flash_messages def render_flash_messages
flash.map do |entry, messages| flash.map do |entry, messages|
# 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.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"}) onclick: "this.parentElement.remove();")
end end
end end
end.join.html_safe end.join.html_safe
@@ -250,6 +204,8 @@ module ApplicationHelper
private private
def link_or_button_options(type, name, image = nil, html_options) def link_or_button_options(type, name, image = nil, html_options)
name = svg_tag("pictograms/#{image}", name) if image
html_options[:class] = class_names( html_options[:class] = class_names(
html_options[:class], html_options[:class],
'button', 'button',
@@ -260,18 +216,10 @@ module ApplicationHelper
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');" html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end end
link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream) if type == :link && !(html_options[:onclick] || html_options.dig(:data, :turbo_stream))
name = name.to_s name += '...'
name += '...' if type == :link && !link_is_local end
name = svg_tag("pictograms/#{image}", name) if image
[name, html_options] [name, html_options]
end end
# Like Hash#deep_merge, but aware of HTML attributes.
def merge_attributes(left, right)
left.deep_merge(right) do |key, lvalue, rvalue|
key == :class ? class_names(lvalue, rvalue) : rvalue
end
end
end end

View File

@@ -1,11 +1,7 @@
module QuantitiesHelper module QuantitiesHelper
def quantities_check_boxes(quantities) def quantity_options(quantities, selected: nil)
# Closing <details> on focusout event depends on relatedTarget for internal values = quantities.map { |q| [sanitize('&emsp;' * q.depth + q.name), q.id] }
# actions being non-null. To ensure this, all top-layer elements of values.unshift([t('.select_quantity'), nil, {hidden: true}])
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */ options_for_select(values, selected: selected)
collection_check_boxes(nil, :quantity, quantities, :id, :to_s_with_depth,
include_hidden: false) do |b|
content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
end
end end
end end

View File

@@ -1,51 +1,39 @@
// 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"
/* Hide page before loaded for testing purposes */ /* Hide page before loaded 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 */ /* Turbo stream actions */
Turbo.StreamElement.prototype.disableElement = function(element) {
element.setAttribute("disabled", "disabled")
element.setAttribute("aria-disabled", "true")
element.setAttribute("tabindex", "-1")
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => { this.disableElement(e) })
}
Turbo.StreamElement.prototype.enableElement = function(element) { Turbo.StreamElement.prototype.enableElement = function(element) {
element.removeAttribute("disabled") element.removeAttribute("disabled")
element.removeAttribute("aria-disabled") element.removeAttribute("aria-disabled")
// Assume 'tabindex' is not used explicitly, so removing it is safe // 'tabindex' is not used explicitly, so removing it is safe
element.removeAttribute("tabindex") element.removeAttribute("tabindex")
} }
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => {
e.setAttribute("disabled", "disabled")
e.setAttribute("aria-disabled", "true")
e.setAttribute("tabindex", "-1")
})
}
Turbo.StreamActions.enable = function() { Turbo.StreamActions.enable = function() {
this.targetElements.forEach((e) => { this.enableElement(e) }) 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" })
} }
Turbo.StreamActions.show = function() {
this.targetElements.forEach((e) => { e.style.removeProperty("display") })
}
/*
Turbo.StreamActions.collapse = function() {
this.targetElements.forEach((e) => { e.style.visibility = "collapse" })
}
*/
Turbo.StreamActions.close_form = function() { 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 */
@@ -66,9 +54,88 @@ Turbo.StreamActions.close_form = function() {
}) })
} }
Turbo.StreamActions.unselect = function() {
this.targetElements.forEach((e) => { /* Items table drag and drop support */
e.checked = false function processKey(event) {
this.enableElement(e) if (event.key == "Escape") {
}) event.currentTarget.querySelector("a[name=cancel]").click();
} }
}
window.processKey = processKey;
var lastEnterTime;
function dragStart(event) {
lastEnterTime = event.timeStamp;
var row = event.currentTarget;
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"));
var rowRectangle = row.getBoundingClientRect();
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top);
event.dataTransfer.dropEffect = "move";
}
window.dragStart = dragStart;
/*
* Drag tracking assumptions (based on FF 122.0 experience):
* * Enter/Leave events at the same timeStamp may not be logically ordered
* (e.g. E -> E -> L, not E -> L -> E),
* * not every Enter event has corresponding Leave event, especially during
* rapid pointer moves
* NOTE: sometimes Leave is not emitted when pointer goes fast over table
* and outside. This should probably be fixed in browser, than patched here.
*/
function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
dragLeave(event);
lastEnterTime = event.timeStamp;
const id = event.currentTarget.getAttribute("data-drop-id");
document.getElementById(id).classList.add("dropzone");
}
window.dragEnter = dragEnter;
function dragOver(event) {
event.preventDefault();
}
window.dragOver = dragOver;
function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
// Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return;
event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
tr.classList.remove("dropzone");
});
}
window.dragLeave = dragLeave;
function dragEnd(event) {
dragLeave(event);
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
}
window.dragEnd = dragEnd;
function drop(event) {
event.preventDefault();
var params = new URLSearchParams();
var id_param = event.currentTarget.getAttribute("data-drop-id-param");
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop();
params.append(id_param, id);
fetch(event.dataTransfer.getData("text/plain"), {
body: params,
headers: {
"Accept": "text/vnd.turbo-stream.html",
"X-CSRF-Token": document.head.querySelector("meta[name=csrf-token]").content,
"X-Requested-With": "XMLHttpRequest"
},
method: "POST"
})
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html))
}
window.drop = drop;

View File

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

View File

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

View File

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

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
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
@@ -101,11 +100,6 @@ class Quantity < ApplicationRecord
name name
end end
def to_s_with_depth
# em space, U+2003
'' * depth + name
end
def destroyable? def destroyable?
subquantities.empty? subquantities.empty?
end end

View File

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

View File

@@ -3,8 +3,7 @@ class Unit < ApplicationRecord
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"
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
validate if: ->{ base.present? } do validate if: ->{ base.present? } do
errors.add(:base, :user_mismatch) unless user_id == base.user_id errors.add(:base, :user_mismatch) unless user_id == base.user_id

View File

@@ -12,10 +12,10 @@ class User < ApplicationRecord
disabled: 0, # administratively disallowed to sign in disabled: 0, # administratively disallowed to sign in
}, default: :active, validate: true }, default: :active, validate: true
has_many :readouts, dependent: :delete_all has_many :readouts, dependent: :destroy
accepts_nested_attributes_for :readouts accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :delete_all has_many :quantities, dependent: :destroy
has_many :units, dependent: :delete_all has_many :units, dependent: :destroy
validates :email, presence: true, uniqueness: true, validates :email, presence: true, uniqueness: true,
length: {maximum: type_for_attribute(:email).limit} length: {maximum: type_for_attribute(:email).limit}

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>

View File

@@ -5,7 +5,7 @@
</td> </td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="flex"> <td class="actions">
<% unless unit.portable.nil? %> <% unless unit.portable.nil? %>
<% if unit.default? %> <% if unit.default? %>
<%= image_button_to_if unit.portable?, t('.import'), 'download-outline', <%= image_button_to_if unit.portable?, t('.import'), 'download-outline',

View File

@@ -1,17 +1,16 @@
<div class="rightside-area buttongrid"> <div class="rightside buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%# TODO: implement Import all %> <%# TODO: implement Import all %>
<%#= image_button_to t('.import_all'), 'download-multiple-outline', <%#= image_button_to t('.import_all'), 'download-multiple-outline',
import_all_default_units_path, data: {turbo_stream: true} %> import_all_default_units_path, data: {turbo_stream: true} %>
<% end %> <% end %>
<%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path, <%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path, class: 'tools' %>
class: 'tools-area' %>
</div> </div>
<table class="main-area items-table"> <table class="main items">
<thead> <thead>
<tr> <tr>
<th><%= Unit.human_attribute_name(:symbol) %></th> <th><%= User.human_attribute_name(:symbol).capitalize %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t '.actions' %></th> <th><%= t '.actions' %></th>
<% end %> <% end %>

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 %>
@@ -24,10 +23,10 @@
</head> </head>
<body> <body>
<header class="flex"> <header class="hflex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %> <%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url, <%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "rextend" %> class: "extendedright" %>
<% if user_signed_in? %> <% if user_signed_in? %>
<%= image_link_to_unless_current(current_user, "account-wrench-outline", <%= image_link_to_unless_current(current_user, "account-wrench-outline",
edit_user_registration_path) %> edit_user_registration_path) %>
@@ -48,7 +47,7 @@
<%= render_flash_messages %> <%= render_flash_messages %>
</div> </div>
<%# Allows overwriting/clearing navigation menu for some views %> <%# Allow overwriting/clearing navigation menu for some views %>
<nav class="navigation"> <nav class="navigation">
<%= content_for(:navigation) || (navigation_menu if user_signed_in?) %> <%= content_for(:navigation) || (navigation_menu if user_signed_in?) %>
</nav> </nav>

View File

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

View File

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

View File

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

View File

@@ -1,39 +1,29 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form, <%= tabular_form_with model: Measurement.new do |form| %>
class: 'topside-area flex vertical center', <fieldset>
html: {data: {controller: 'form', action: 'keydown->form#processKey'}} do |form| %> <legend>
<%= tag.span id: :measurement_form_legend %>
<table class="items-table center"> <%= image_link_to '', "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</legend>
<table class="items">
<tbody id="readouts"> <tbody id="readouts">
<%= tabular_fields_for @measurement do |form| %> <tr id="readouts_form">
<tr class="italic"> <td>
<td class="hexpand hmin50"><%= t '.taken_at_html' %></td> <%= select_tag :id, quantity_options(@quantities), onchange:
<td colspan="3" class="ralign"> "this.form.requestSubmit(document.getElementById('readout_submit'));",
<%= form.datetime_field :taken_at, required: true, value: Time.current.strftime('%Y-%m-%dT%H:%M') %> class: 'quantity' %>
<%= form.submit id: :readout_submit, name: nil, value: nil,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
hidden: true, data: {turbo_stream: true} %>
</td> </td>
<td></td>
<td></td>
<td></td>
</tr>
<tr id="measurement_form_actions">
<td colspan="4"><div class="actions centered"><%= form.button %></div></td>
</tr> </tr>
<% end %>
</tbody> </tbody>
</table> </table>
</fieldset>
<%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open
data-controller="details"
data-action="focusout->details#close change->details#change keydown->details#processKey">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>" data-details-target="countLabel">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true, details_target: 'submitButton'} %>
</summary>
<ul data-details-target="list"><%= quantities_check_boxes(@quantities) %></ul>
</details>
<div class="flex reverse">
<%= form.button id: :create_measurement_button, disabled: true -%>
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %> <% end %>

View File

@@ -1,4 +1,2 @@
<%= turbo_stream.update :measurement_form %>
<%= turbo_stream.update :flashes %> <%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.show :no_items -%>
<%= turbo_stream.enable :new_measurement_link -%>

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,10 @@
<%# 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 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"> <%= tag.div class: 'main', id: :measurement_form %>
<%= tag.div id: :measurement_edit_form %>
<table class="items-table measurements-compact" data-controller="measurements">
<thead>
<tr>
<th><%= Quantity.model_name.human %></th>
<th><%= Readout.human_attribute_name(:value) %></th>
<th><%= Unit.model_name.human %></th>
<th data-column="taken-at"><%= Readout.human_attribute_name(:taken_at) %></th>
<th data-column="created-at"><%= Readout.human_attribute_name(:created_at) %></th>
<% if current_user.at_least(:active) %>
<th></th>
<% end %>
</tr>
</thead>
<tbody id="measurements" data-measurements-target="tbody">
<%= render(partial: 'readout', collection: @measurements, as: :readout) || render_no_items %>
</tbody>
</table>
<div id="measurements-wide" class="measurements-wide">
<%= render 'wide_table', wide_groups: @wide_groups, wide_quantities: @wide_quantities %>
</div>
</div>

View File

@@ -1,5 +1,4 @@
<%= turbo_stream.disable :new_measurement_link -%> <%= turbo_stream.disable :new_measurement_link -%>
<%= turbo_stream.hide :no_items -%> <%= turbo_stream.update :measurement_form do %>
<%= turbo_stream.append_all 'body' do %>
<%= render partial: 'form' %> <%= render partial: 'form' %>
<% end %> <% end %>

View File

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

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: "processKey(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,13 +8,8 @@
<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="actions">
<%= form.button %> <%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous', <%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %> name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>

View File

@@ -1,20 +1,18 @@
<%= 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 class="link" style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link', <%= link_to quantity, edit_quantity_path(quantity), onclick: 'this.blur();',
onclick: 'this.blur();', data: {turbo_stream: true} %> 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="actions">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity), <%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %> id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>

View File

@@ -1,33 +1,30 @@
<div class="rightside-area buttongrid"> <div class="rightside buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path, <%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();', id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %> data: {turbo_stream: true} %>
<% end %> <% end %>
<%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path, <%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
class: 'tools-area' %> class: 'tools' %>
</div> </div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %> <%= tag.div class: 'main', id: :quantity_form %>
<%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main-area items-table"> <table class="main items">
<thead> <thead>
<tr> <tr>
<th><%= Quantity.human_attribute_name(:name) %></th> <th><%= Quantity.human_attribute_name(:name).capitalize %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th> <th><%= Quantity.human_attribute_name(:description).capitalize %></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">

View File

@@ -1,31 +1,28 @@
<%= 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),
data: {controller: 'readout-unit'} do %> onkeydown: 'processKey(event)' do %>
<td> <td>
<%# TODO: add grayed readout index (in separate column?) %> <%#= readout.quantity.relative_pathname(@common_ancestor) %>
<%= readout.quantity.relative_pathname(@superquantity) %> <%= form.collection_select :quantity_id, @user_quantities,
<%= form.hidden_field :quantity_id %> :id, ->(q){ sanitize('&emsp;' * q.depth + q.name) },
{disabled: @quantities.map(&:id).delete!(form.object.quantity.id)},
{class: 'quantity'} %>
</td> </td>
<td> <td>
<%= form.number_field :value, required: true, autofocus: readout_counter == 0 %> <%= form.number_field :value, required: true, autofocus: true, size: 10 %>
</td> </td>
<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,
data: {default_unit_id: readout.quantity.default_unit_id || '',
readout_unit_target: 'select',
action: 'change->readout-unit#unitChanged'} %>
</td> </td>
<td class="flex">
<%# TODO: change to _link_ after giving up displaying relative paths %> <td class="actions">
<%= image_button_tag '', 'check-circle-outline', <%= image_button_tag t('.new_children'), 'plus-multiple-outline',
class: 'set-default-unit', name: nil, type: 'button', disabled: true, formaction: new_measurement_path(readout.quantity, :children),
title: t('readouts.form.set_default_unit'), formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
data: {path: quantity_path(readout.quantity), <%#= image_button_tag t('.new_subtree'), 'plus-multiple-outline',
readout_unit_target: 'button', formaction: new_measurement_path(:subtree), **common_options -%>
action: 'click->readout-unit#setDefault'} %> <%= image_button_tag '', 'delete-outline', class: 'dangerous',
<%= 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} %>
</td> </td>

View File

@@ -0,0 +1,2 @@
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>

View File

@@ -1,7 +1,7 @@
<%= turbo_stream.update(:measurement_form_legend) { @superquantity&.pathname } %> <%= turbo_stream.update(:measurement_form_legend) { @common_ancestor&.pathname } %>
<% @prev_quantities.each do |pq| %> <% @prev_quantities.each do |pq| %>
<%= turbo_stream.update dom_id(pq, nil, :pathname) do %> <%= turbo_stream.update dom_id(pq, nil, :pathname) do %>
<%= pq.relative_pathname(@superquantity) %> <%= pq.relative_pathname(@common_ancestor) %>
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -1,4 +0,0 @@
<%= turbo_stream.disable :create_measurement_button if @prev_quantities.one? %>
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>

View File

@@ -1,8 +1,8 @@
<%#= render partial: 'form_repath' %>
<% @readouts.each do |r| %> <% @readouts.each do |r| %>
<%= turbo_stream.disable dom_id(r.quantity) %> <%= turbo_stream.disable_all "select.quantity option[value='#{r.quantity_id}']" %>
<% end %> <% end %>
<%= render partial: 'form_repath' %> <%= turbo_stream.before :readouts_form do %>
<%= turbo_stream.append :readouts do %>
<%= render partial: 'form', collection: @readouts, as: :readout %> <%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %> <% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>

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: "processKey(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 %>
@@ -9,11 +8,11 @@
<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> <td class="number">
<%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %> <%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %>
</td> </td>
<td class="flex"> <td class="actions">
<%= form.button %> <%= form.button %>
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous', <%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %> name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>

View File

@@ -1,20 +1,19 @@
<%= 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 class="link" 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), 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="number"><%= unit.multiplier.to_html %></td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="flex"> <td class="actions">
<% unless unit.base_id? %> <% unless unit.base_id? %>
<%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit), <%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %> id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>

View File

@@ -1,31 +1,28 @@
<div class="rightside-area buttongrid"> <div class="rightside buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path, <%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path,
id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %> id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<% end %> <% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path, <%= image_link_to t('.import_units'), 'download-outline', default_units_path, class: 'tools' %>
class: 'tools-area' %>
</div> </div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div id: :unit_form %> <%= tag.div id: :unit_form %>
<table class="main-area items-table"> <table class="main items">
<thead> <thead>
<tr> <tr>
<th><%= Unit.human_attribute_name(:symbol) %></th> <th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %></th> <th><%= User.human_attribute_name(:description).capitalize %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></th> <th><%= User.human_attribute_name(:multiplier).capitalize %></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: "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>

View File

@@ -1 +0,0 @@
<% flash.discard %>

View File

@@ -0,0 +1,8 @@
<div class="main">
<%= labelled_form_for resource, url: user_confirmation_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email",
value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>
<%= f.submit t(:resend_confirmation) %>
<% end %>
</div>

View File

@@ -1,34 +1,36 @@
<table class="main-area items-table" id="users"> <table class="main items" id="users">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:email) %></th> <th><%= User.human_attribute_name(:email).capitalize %></th>
<th><%= User.human_attribute_name(:status) %></th> <th><%= User.human_attribute_name(:status).capitalize %></th>
<th><%= User.human_attribute_name(:confirmed_at) %></th> <th><%= User.human_attribute_name(:confirmed_at).capitalize %></th>
<th><%= User.human_attribute_name(:created_at) %>&nbsp;<sup>(UTC)</sup></th> <th>
<%= User.human_attribute_name(:created_at).capitalize %>&nbsp;<sup>UTC</sup>
</th>
<th><%= t :actions %></th> <th><%= t :actions %></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% @users.each do |user| %> <% @users.each do |user| %>
<tr> <tr>
<td><%= link_to user, user_path(user), class: 'link' %></td> <td class="link"><%= link_to user, user_path(user) %></td>
<td> <td>
<% if user == current_user %> <% if user == current_user %>
<%= user.status %> <%= user.status %>
<% else %> <% else %>
<%= form_for user do |u| %> <%= form_for user do |u| %>
<%= u.select :status, User.statuses.keys, {}, autocomplete: 'off', <%= u.select :status, User.statuses.keys, {}, autocomplete: "off",
onchange: 'this.form.requestSubmit();' %> onchange: "this.form.requestSubmit();" %>
<% end %> <% end %>
<% end %> <% end %>
</td> </td>
<td> <td class="svg">
<%= svg_tag 'pictograms/checkbox-marked-outline' if user.confirmed_at.present? %> <%= svg_tag "pictograms/checkbox-marked-outline" if user.confirmed_at.present? %>
</td> </td>
<td><%= l user.created_at, format: :without_tz %></td> <td><%= user.created_at.to_fs(:db_without_sec) %></td>
<td class="flex"> <td class="actions">
<% 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) %>
<% end %> <% end %>
</td> </td>
</tr> </tr>

View File

@@ -1,5 +1,5 @@
<p>Welcome <%= @email %>!</p> <p>Welcome <%= @email %>!</p>
<p>You can confirm your account email through the link below:</p> <p>You can confirm your account email through the link below:</p>
<!-- FIXME: is confirmation_url valid route prefix? -->
<p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p> <p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>

View File

@@ -1,2 +0,0 @@
<%# For some reason flash messages are duplicated in bot flash and flash.now %>
<% flash.discard %>

View File

@@ -1,12 +1,13 @@
<%= labeled_form_for resource, url: user_password_path, <div class="main">
html: {method: :put, class: 'main-area', data: {turbo: false}} do |f| %> <%= labelled_form_for resource, url: user_password_path, html: {method: :put} do |f| %>
<%= f.hidden_field :reset_password_token, label: false %>
<%= f.hidden_field :reset_password_token %> <%= f.password_field :password, label: t(".new_password"), required: true, size: 30,
minlength: @minimum_password_length, autofocus: true, autocomplete: "new-password",
hint: t("users.minimum_password_length", count: @minimum_password_length) %>
<%= f.password_field :password_confirmation, label: t(".password_confirmation"),
required: true, size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.password_field :password, required: true, size: 30, autofocus: true, <%= f.submit t(".update_password") %>
minlength: @minimum_password_length, autocomplete: 'new-password' %>
<%= f.password_field :password_confirmation, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t('.update_password') %>
<% end %> <% end %>
</div>

View File

@@ -0,0 +1,7 @@
<div class="main">
<%= labelled_form_for resource, url: user_password_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
<%= f.submit t(:recover_password) %>
<% end %>
</div>

View File

@@ -1,30 +0,0 @@
<% content_for :navigation, flush: true do %>
<%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)),
request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %>
<div class="rightside-area buttongrid">
<%#= TODO: Disallow/disable deletion for last admin account, image_button_to_if %>
<%= image_button_to t('.delete'), 'account-remove-outline', user_registration_path,
form_class: 'tools-area', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %>
</div>
<%= labeled_form_for resource, url: registration_path(resource),
html: {method: :patch, class: 'main-area'} do |f| %>
<%= f.email_field :email, size: 30, autofocus: true, autocomplete: 'off' %>
<% if resource.pending_reconfirmation? %>
<%= f.text_field :unconfirmed_email, readonly: true,
confirmation_sent_at: l(resource.confirmation_sent_at) %>
<% end %>
<%= f.select :status, User.statuses, readonly: true %>
<%= f.password_field :password, size: 30, autocomplete: 'new-password',
minlength: @minimum_password_length %>
<%= f.password_field :password_confirmation, size: 30, autocomplete: 'off',
minlength: @minimum_password_length %>
<%= f.submit %>
<% end %>

View File

@@ -1,16 +0,0 @@
<%= labeled_form_for resource, url: user_registration_path,
html: {class: 'main-area', data: {controller: 'form', action: 'submit->form#validate'}} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'new-password' %>
<%= f.password_field :password_confirmation, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t(:register), data: {turbo: false} %>
<%= image_button_tag t(:resend_confirmation), 'email-sync-outline',
class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
<% end %>

View File

@@ -0,0 +1,31 @@
<% content_for :navigation, flush: true do %>
<%= link_to svg_tag("pictograms/arrow-left-bold-outline", t(:back)),
request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %>
<div class="rightside buttongrid">
<%= image_button_to t(".delete"), "account-remove-outline", user_registration_path,
form_class: 'tools', method: :delete, data: {turbo: false},
onclick: {confirm: t(".confirm_delete")} %>
</div>
<%= labelled_form_for resource, url: registration_path(resource),
html: {method: :patch, class: 'main'} do |f| %>
<%= f.email_field :email, size: 30, autofocus: true, autocomplete: "off" %>
<% if f.object.pending_reconfirmation? %>
<%= f.text_field :unconfirmed_email, readonly: true, tabindex: -1,
hint: t(".unconfirmed_email_hint",
timestamp: f.object.confirmation_sent_at.to_fs(:db_without_sec)) %>
<% end %>
<%= f.select :status, User.statuses, readonly: true %>
<%= f.password_field :password, label: t(".new_password"), size: 30,
minlength: @minimum_password_length, autocomplete: "new-password",
hint: t(".blank_password_hint_html",
subhint: t("users.minimum_password_length", count: @minimum_password_length)) %>
<%= f.password_field :password_confirmation, label: t(".password_confirmation"),
size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.submit t(".update") %>
<% end %>

View File

@@ -0,0 +1,16 @@
<div class="main">
<%= labelled_form_for resource, url: user_registration_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
<%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: "new-password",
hint: t("users.minimum_password_length", count: @minimum_password_length) %>
<%= f.password_field :password_confirmation, label: t(".password_confirmation"),
required: true, size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.submit t(:register) %>
<% end %>
<%= content_tag :p, t(:or), style: "text-align: center;" %>
<%= image_link_to t(:resend_confirmation), "email-sync-outline", new_user_confirmation_path,
class: "centered" %>
</div>

View File

@@ -1,19 +1,17 @@
<%= labeled_form_for resource, url: user_session_path, <div class="main">
html: {class: 'main-area', data: {controller: 'form', action: 'submit->form#validate'}} do |f| %> <%= labelled_form_for resource, url: user_session_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, <%= f.password_field :password, required: true, size: 30, minlength: @minimum_password_length,
autocomplete: 'email' %> autocomplete: "current-password" %>
<%= f.password_field :password, required: true, size: 30,
autocomplete: 'current-password' %>
<% if devise_mapping.rememberable? %> <% if devise_mapping.rememberable? %>
<%= f.check_box :remember_me %> <%= f.check_box :remember_me, label: t(".remember_me") %>
<% end %> <% end %>
<%# /sign_in as HTML; /password as TURBO_STREAM %> <%= f.submit t(:sign_in) %>
<%= f.submit t(:sign_in), data: {turbo: false} %>
<%= image_button_tag t(:recover_password), 'lock-reset', class: 'auxiliary',
formaction: user_password_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
<% end %> <% end %>
<%= content_tag :p, t(:or), style: "text-align: center;" %>
<%= image_link_to t(:recover_password), 'lock-reset', new_user_password_path,
class: 'centered' %>
</div>

View File

@@ -1,18 +1,19 @@
<% 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, <div class="left">
class: 'tab' %> <%= image_link_to t(:back), "arrow-left-bold-outline", users_path %>
</div>
<% end %> <% end %>
<%= labeled_form_for @user, html: {class: 'main-area'} do |f| %> <%= labelled_form_for @user do |f| %>
<%= f.email_field :email, readonly: true %> <%= f.email_field :email, readonly: true %>
<% if @user.pending_reconfirmation? %> <% if f.object.pending_reconfirmation? %>
<%= f.email_field :unconfirmed_email, readonly: true, <%= f.email_field :unconfirmed_email, readonly: true,
confirmation_sent_at: l(@user.confirmation_sent_at) %> hint: t("users.registrations.edit.unconfirmed_email_hint",
timestamp: f.object.confirmation_sent_at.to_fs(:db_without_sec)) %>
<% end %> <% end %>
<%# TODO: allow status change here? %>
<%= f.select :status, User.statuses, readonly: true %> <%= f.select :status, User.statuses, readonly: true %>
<%= f.text_field :created_at, readonly: true %> <%= f.text_field :created_at, readonly: true %>
<%= f.text_field :confirmed_at, readonly: true, placeholder: t(:no) %> <%= f.text_field :confirmed_at, readonly: true %>
<% end %> <% end %>

View File

@@ -8,7 +8,7 @@
<%= f.email_field :email, autofocus: true, autocomplete: "email" %> <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div> </div>
<div class="flex"> <div class="actions">
<%= f.submit "Resend unlock instructions" %> <%= f.submit "Resend unlock instructions" %>
</div> </div>
<% end %> <% end %>

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ Devise.setup do |config|
# It will change confirmation, password recovery and other workflows # It will change confirmation, password recovery and other workflows
# to behave the same regardless if the e-mail provided was right or wrong. # to behave the same regardless if the e-mail provided was right or wrong.
# Does not affect registerable. # Does not affect registerable.
config.paranoid = true # config.paranoid = true
# By default Devise will store the user in session. You can skip storage for # By default Devise will store the user in session. You can skip storage for
# particular strategies by setting this option. # particular strategies by setting this option.

View File

@@ -0,0 +1,3 @@
# Format contains non-breaking space:
# 160.chr(Encoding::UTF_8)
Time::DATE_FORMATS[:db_without_sec] = "%Y-%m-%d %H:%M"

View File

@@ -19,19 +19,7 @@ ActiveSupport.on_load :turbo_streams_tag_builder do
action :hide, target, allow_inferred_rendering: false action :hide, target, allow_inferred_rendering: false
end end
def show(target)
action :show, target, allow_inferred_rendering: false
end
#def collapse(target)
# action :collapse, target, allow_inferred_rendering: false
#end
def close_form(target) def close_form(target)
action :close_form, target, allow_inferred_rendering: false action :close_form, target, allow_inferred_rendering: false
end end
def unselect(target)
action :unselect, target, allow_inferred_rendering: false
end
end end

View File

@@ -4,15 +4,15 @@ en:
devise: devise:
confirmations: confirmations:
confirmed: "Your email address has been successfully confirmed." confirmed: "Your email address has been successfully confirmed."
send_paranoid_instructions: > send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
If your email address is in our database, a message with instructions on how send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
to confirm your email address has been sent to you.
failure: failure:
already_authenticated: "You are already signed in." already_authenticated: "You are already signed in."
inactive: "Your account is not activated yet." inactive: "Your account is not activated yet."
invalid: "Invalid <b>%{authentication_keys}</b> or <b>password</b>." invalid: "Invalid %{authentication_keys} or password."
locked: "Your account is locked." locked: "Your account is locked."
last_attempt: "You have one more attempt before your account is locked." last_attempt: "You have one more attempt before your account is locked."
not_found_in_database: "Invalid %{authentication_keys} or password."
timeout: "Your session expired. Please sign in again to continue." timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing." unauthenticated: "You need to sign in or sign up before continuing."
unconfirmed: "You have to confirm your email address before continuing." unconfirmed: "You have to confirm your email address before continuing."
@@ -32,9 +32,8 @@ en:
success: "Successfully authenticated from %{kind} account." success: "Successfully authenticated from %{kind} account."
passwords: passwords:
no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
send_paranoid_instructions: > send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
If your email address is in our database, the password recovery link has been send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
sent to you.
updated: "Your password has been changed successfully. You are now signed in." updated: "Your password has been changed successfully. You are now signed in."
updated_not_active: "Your password has been changed successfully." updated_not_active: "Your password has been changed successfully."
registrations: registrations:
@@ -51,6 +50,7 @@ en:
signed_out: "Signed out successfully." signed_out: "Signed out successfully."
already_signed_out: "Signed out successfully." already_signed_out: "Signed out successfully."
unlocks: unlocks:
send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
unlocked: "Your account has been unlocked successfully. Please sign in to continue." unlocked: "Your account has been unlocked successfully. Please sign in to continue."
errors: errors:

View File

@@ -1,33 +1,22 @@
en: en:
time:
formats:
# Format contains non-breaking space: 160.chr(Encoding::UTF_8)
default: "%Y-%m-%d %H:%M %Z"
without_tz: "%Y-%m-%d %H:%M"
errors: errors:
messages: messages:
precision_exceeded: must not exceed %{value} significant digits precision_exceeded: must not exceed %{value} significant digits
scale_exceeded: must not exceed %{value} decimal digits scale_exceeded: must not exceed %{value} decimal digits
activerecord: activerecord:
attributes: attributes:
quantity:
default_unit: Default unit
description: Description
name: Name
readout:
created_at: Recorded at
taken_at: Taken at
value: Value
unit: unit:
base: Base unit
description: Description
multiplier: Multiplier
symbol: Symbol symbol: Symbol
name: Name
multiplier: Multiplier
base: Base unit
user: user:
confirmed_at: Confirmed email: e-mail
created_at: Registered status: status
email: E-mail password: password
status: Status created_at: registered
confirmed_at: confirmed
unconfirmed_email: Awaiting confirmation for
errors: errors:
models: models:
unit: unit:
@@ -64,56 +53,25 @@ en:
The request is semantically incorrect and was rejected (422 Unprocessable Entity). The request is semantically incorrect and was rejected (422 Unprocessable Entity).
This should not happen, please notify site administrator. This should not happen, please notify site administrator.
helpers: helpers:
label:
user:
password_confirmation: 'Retype new password:'
password_length_hint_html:
count: '%{minimum_password_length}'
zero:
one: <br><em>(%{count} character minimum)</em>
other: <br><em>(%{count} characters minimum)</em>
remember_me: 'Remember me:'
unconfirmed_email_html: >
Awaiting confirmation for:<br><em>(since %{confirmation_sent_at})</em>
submit: submit:
create: Create create: Create
update: Update update: Update
user:
update: Update profile
layouts: layouts:
application: application:
issue_tracker: Report issue issue_tracker: Report issue
revert: Revert revert: Revert
sign_out: Sign out sign_out: Sign out
source_code: Get code source_code: Get code
readouts:
form:
set_default_unit: Set as default unit
charts:
navigation: Charts
measurements: measurements:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
form: form:
select_quantity: select quantities... select_quantity: select quantity...
taken_at_html: Measurement taken at&emsp;
index: index:
new_measurement: Add measurement new_measurement: Add measurement
view_compact: Compact view readouts:
view_wide: Wide view form:
view_charts: Charts new_children: Children
readout: new_subtree: Subtree
edit: Edit
destroy: Delete
create:
success:
one: Recorded 1 measurement.
other: Recorded %{count} measurements.
no_readouts: No readouts selected.
update:
success: Measurement updated.
destroy:
success: Measurement deleted.
quantities: quantities:
navigation: Quantities navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults. no_items: There are no configured quantities. You can Add some or Import from defaults.
@@ -171,27 +129,34 @@ en:
disguise: View as disguise: View as
passwords: passwords:
edit: edit:
password_html: 'New password:%{password_length_hint_html}' new_password: New password
password_confirmation: Retype new password
update_password: Update password update_password: Update password
profiles: registrations:
new: new:
password_html: 'Password:%{password_length_hint_html}' password_confirmation: Retype password
password_confirmation: 'Retype password:'
edit: edit:
confirm_delete: Are you sure you want to delete profile? confirm_delete: Are you sure you want to delete profile?
All data will be irretrievably lost. All data will be irretrievably lost.
delete: Delete profile delete: Delete profile
password_html: > unconfirmed_email_hint: (since %{timestamp})
New password: new_password: New password
<br><em>leave blank to keep unchanged</em> password_confirmation: Retype new password
%{password_length_hint_html} blank_password_hint_html: leave blank to keep unchanged<br>%{subhint}
update: Update profile
sessions:
new:
remember_me: Remember me
minimum_password_length:
zero:
one: (%{count} character minimum)
other: (%{count} characters minimum)
actions: Actions actions: Actions
add: Add add: Add
apply: Apply
back: Back back: Back
cancel: Cancel cancel: Cancel
delete: Delete delete: Delete
:no: 'no' or: or
register: Register register: Register
sign_in: Sign in sign_in: Sign in
recover_password: Recover password recover_password: Recover password

View File

@@ -1,8 +1,9 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :measurements resources :measurements
resources :charts, only: [:index]
resources :readouts, only: [:new] do resources :readouts, only: [:new], path_names: {new: '/new(/:id/:scope)'},
constraints: {scope: /children|subtree|leaves/} do
collection {get 'new/:id/discard', action: :discard, as: :discard} collection {get 'new/:id/discard', action: :discard, as: :discard}
end end
@@ -25,9 +26,8 @@ Rails.application.routes.draw do
# https://github.com/heartcombo/devise/issues/5786 # https://github.com/heartcombo/devise/issues/5786
connection = ActiveRecord::Base.connection connection = ActiveRecord::Base.connection
if connection.schema_version && connection.table_exists?(:users) if connection.schema_version && connection.table_exists?(:users)
# NOTE: change helper prefix from *_registration to *_profile once possible
devise_for :users, path: '', path_names: {registration: 'profile'}, devise_for :users, path: '', path_names: {registration: 'profile'},
controllers: {registrations: 'user/profiles'} controllers: {registrations: :registrations}
end end
resources :users, only: [:index, :show, :update] do resources :users, only: [:index, :show, :update] do
@@ -36,8 +36,10 @@ Rails.application.routes.draw do
end end
unauthenticated do unauthenticated do
as :user do
root to: redirect('/sign_in') root to: redirect('/sign_in')
end end
end
root to: redirect('/units'), as: :user_root root to: redirect('/units'), as: :user_root
direct(:source_code) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me' } direct(:source_code) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me' }

View File

@@ -5,7 +5,7 @@ class CreateUnits < ActiveRecord::Migration[7.0]
t.string :symbol, null: false, limit: 15 t.string :symbol, null: false, limit: 15
t.text :description t.text :description
t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0 t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0
t.references :base, foreign_key: {to_table: :units, on_delete: :cascade} t.references :base, foreign_key: {to_table: :units}
t.timestamps null: false t.timestamps null: false
end end

View File

@@ -4,7 +4,7 @@ class CreateQuantities < ActiveRecord::Migration[7.2]
t.references :user, foreign_key: true t.references :user, foreign_key: true
t.string :name, null: false, limit: 31 t.string :name, null: false, limit: 31
t.text :description t.text :description
t.references :parent, foreign_key: {to_table: :quantities, on_delete: :cascade} t.references :parent, foreign_key: {to_table: :quantities}
t.timestamps null: false t.timestamps null: false

View File

@@ -1,14 +1,10 @@
class CreateReadouts < ActiveRecord::Migration[7.2] class CreateReadouts < ActiveRecord::Migration[7.2]
def change def change
create_table :readouts do |t| create_table :readouts do |t|
# Reference :user through :quantity (:measurement may be NULL). t.references :user, null: false, foreign_key: true
t.references :measurement, foreign_key: true
t.references :quantity, null: false, foreign_key: true t.references :quantity, null: false, foreign_key: true
# :category + :value + :unit as a separate table? (NumericValue, TextValue)
t.integer :category, null: false, default: 0
t.float :value, null: false, limit: Float::MANT_DIG
t.references :unit, foreign_key: true t.references :unit, foreign_key: true
# Move to Measurement? t.decimal :value, null: false, precision: 30, scale: 15
#t.references :collector, foreign_key: true #t.references :collector, foreign_key: true
#t.references :device, foreign_key: true #t.references :device, foreign_key: true

View File

@@ -1,6 +0,0 @@
class AddTakenAtToReadouts < ActiveRecord::Migration[7.2]
def change
add_column :readouts, :taken_at, :datetime
add_index :readouts, [:user_id, :taken_at]
end
end

View File

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

View File

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

View File

@@ -21,4 +21,3 @@ end
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all } #[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
require_relative 'seeds/units.rb' require_relative 'seeds/units.rb'
require_relative 'seeds/demo.rb'

View File

@@ -1,87 +0,0 @@
demo_email = 'demo@localhost'
User.transaction do
break if User.find_by(email: demo_email)
demo = User.create! email: demo_email, password: 'demo123', status: :active do |user|
user.skip_confirmation!
print "Creating demo account '#{user.email}' with password '#{user.password}'..."
end
puts "done."
# --- Units ---
u = {}
u[:kg] = demo.units.create! symbol: 'kg', description: 'kilogram'
u[:g] = demo.units.create! symbol: 'g', description: 'gram', base: u[:kg], multiplier: 1e-3
u[:cm] = demo.units.create! symbol: 'cm', description: 'centimetre'
u[:bpm] = demo.units.create! symbol: 'bpm', description: 'beats per minute'
u[:pct] = demo.units.create! symbol: '%', description: 'percent'
u[:h] = demo.units.create! symbol: 'h', description: 'hour'
u[:min] = demo.units.create! symbol: 'min', description: 'minute', base: u[:h], multiplier: (1.0/60).round(10)
u[:kcal]= demo.units.create! symbol: 'kcal',description: 'kilocalorie'
u[:mg] = demo.units.create! symbol: 'mg', description: 'milligram', base: u[:kg], multiplier: 1e-6
u[:mmhg]= demo.units.create! symbol: 'mmHg',description: 'millimetre of mercury'
# --- Quantities ---
body = demo.quantities.create! name: 'Body'
weight = demo.quantities.create! name: 'Weight', parent: body
height = demo.quantities.create! name: 'Height', parent: body
fat = demo.quantities.create! name: 'Body fat', parent: body
cardio = demo.quantities.create! name: 'Cardiovascular'
hr_rest = demo.quantities.create! name: 'Resting HR', parent: cardio
hr_peak = demo.quantities.create! name: 'Peak HR', parent: cardio
bp_sys = demo.quantities.create! name: 'BP systolic', parent: cardio
bp_dia = demo.quantities.create! name: 'BP diastolic', parent: cardio
activity = demo.quantities.create! name: 'Activity'
sleep_dur = demo.quantities.create! name: 'Sleep', parent: activity
calories = demo.quantities.create! name: 'Calories out', parent: activity
nutrition = demo.quantities.create! name: 'Nutrition'
cal_in = demo.quantities.create! name: 'Calories in', parent: nutrition
caffeine = demo.quantities.create! name: 'Caffeine', parent: nutrition
# --- Readouts (60 days of daily-ish data) ---
base_time = 60.days.ago.beginning_of_day
rng = Random.new(42)
weight_val = 82.4
fat_val = 21.5
hr_rest_val = 62.0
60.times do |i|
t = base_time + i.days + rng.rand(3600 * 2)
weight_val += rng.rand(-0.3..0.3)
fat_val += rng.rand(-0.15..0.15)
hr_rest_val += rng.rand(-1.5..1.5)
hr_rest_val = hr_rest_val.clamp(52, 72)
demo.readouts.create! quantity: weight, unit: u[:kg], value: weight_val.round(1), created_at: t
demo.readouts.create! quantity: fat, unit: u[:pct], value: fat_val.round(1), created_at: t
demo.readouts.create! quantity: hr_rest, unit: u[:bpm], value: hr_rest_val.round, created_at: t
if i % 2 == 0
demo.readouts.create! quantity: bp_sys, unit: u[:mmhg], value: (115 + rng.rand(-8..8)).round, created_at: t
demo.readouts.create! quantity: bp_dia, unit: u[:mmhg], value: (75 + rng.rand(-5..5)).round, created_at: t
end
if i % 3 == 0
demo.readouts.create! quantity: hr_peak, unit: u[:bpm], value: (155 + rng.rand(-10..10)).round, created_at: t
end
demo.readouts.create! quantity: sleep_dur, unit: u[:h], value: (6.5 + rng.rand(-1.5..1.5)).round(1), created_at: t
demo.readouts.create! quantity: calories, unit: u[:kcal],value: (2100 + rng.rand(-300..300)).round, created_at: t
demo.readouts.create! quantity: cal_in, unit: u[:kcal],value: (1900 + rng.rand(-400..400)).round, created_at: t
if i % 4 == 0
demo.readouts.create! quantity: caffeine, unit: u[:mg], value: (200 + rng.rand(-80..80)).round, created_at: t
end
end
# height is stable — record once
demo.readouts.create! quantity: height, unit: u[:cm], value: 178.0, created_at: base_time
puts " Created #{demo.units.count} units, #{demo.quantities.count} quantities, #{demo.readouts.count} readouts."
end

View File

@@ -1,5 +1,5 @@
Unit.transaction do Unit.transaction do
Unit.defaults.delete_all Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all
units = {} units = {}
<% Unit.defaults.ordered.each do |unit| %> <% Unit.defaults.ordered.each do |unit| %>
<%= "\n" if unit.base.nil? %> <%= "\n" if unit.base.nil? %>

View File

@@ -1,5 +1,5 @@
Unit.transaction do Unit.transaction do
Unit.defaults.delete_all Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all
units = {} units = {}

View File

@@ -1,7 +1,7 @@
require "test_helper" require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
include ActionView::Helpers::SanitizeHelper extend ActionView::Helpers::TranslationHelper
include ActionView::Helpers::UrlHelper include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning # NOTE: geckodriver installed with Firefox, ignore incompatibility warning
@@ -19,8 +19,8 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
def sign_in(user: users.select(&:confirmed?).sample, password: randomize_user_password!(user)) def sign_in(user: users.select(&:confirmed?).sample, password: randomize_user_password!(user))
visit new_user_session_url visit new_user_session_url
fill_in User.human_attribute_name(:email), with: user.email fill_in User.human_attribute_name(:email).capitalize, with: user.email
fill_in User.human_attribute_name(:password), with: password fill_in User.human_attribute_name(:password).capitalize, with: password
click_on t(:sign_in) click_on t(:sign_in)
user user
end end
@@ -30,14 +30,6 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after) evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after)
end end
# Allow skipping interpolations when translating for testing purposes
INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns)
def translate(key, **options)
translation = options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super
sanitize(translation, tags: [])
end
alias :t :translate
#def assert_stale(element) #def assert_stale(element)
# assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name } # assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name }
#end #end

View File

@@ -1,63 +0,0 @@
require "test_helper"
class ChartsControllerTest < ActionDispatch::IntegrationTest
setup do
host! '127.0.0.1'
@user = users(:alice)
post new_user_session_path, params: { user: { email: @user.email, password: 'alice' } }
@quantity = @user.quantities.create!(name: 'Weight')
@unit = @user.units.create!(symbol: 'kg')
end
test "requires authentication" do
delete destroy_user_session_path
get charts_path
assert_response :redirect
end
test "index returns ok" do
get charts_path
assert_response :success
end
test "embeds readout data as JSON in script tag" do
users(:alice).readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago)
get charts_path
assert_select 'script#charts-data[type="application/json"]' do |elements|
data = JSON.parse(elements.first.children.first.to_s)
assert_equal 1, data.size
assert_equal 'Weight', data.first['quantityName']
assert_in_delta 82.5, data.first['value']
assert_equal 'kg', data.first['unit']
assert_not_nil data.first['takenAt']
end
end
test "orders readouts by taken_at ascending" do
older = users(:alice).readouts.create!(quantity: @quantity, unit: @unit, value: 80.0, taken_at: 2.days.ago)
newer = users(:alice).readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago)
get charts_path
assert_select 'script#charts-data[type="application/json"]' do |elements|
data = JSON.parse(elements.first.children.first.to_s)
assert_equal older.taken_at.iso8601, data.first['takenAt']
assert_equal newer.taken_at.iso8601, data.last['takenAt']
end
end
test "does not expose other users readouts" do
bob_quantity = users(:bob).quantities.create!(name: 'Steps')
bob_unit = users(:bob).units.create!(symbol: 'steps')
users(:bob).readouts.create!(quantity: bob_quantity, unit: bob_unit, value: 5000, taken_at: 1.day.ago)
get charts_path
assert_select 'script#charts-data[type="application/json"]' do |elements|
data = JSON.parse(elements.first.children.first.to_s)
assert data.none? { |r| r['quantityName'] == 'Steps' }, "Bob's data must not appear"
end
end
end

View File

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

View File

@@ -1,26 +0,0 @@
require "application_system_test_case"
class ChartsTest < ApplicationSystemTestCase
setup do
@user = sign_in(user: users(:alice))
@quantity = @user.quantities.create!(name: 'Weight')
@unit = @user.units.create!(symbol: 'kg')
@user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5, taken_at: 1.day.ago)
@user.readouts.create!(quantity: @quantity, unit: @unit, value: 83.1, taken_at: Time.now)
visit charts_path
end
test "charts page is reachable from navigation" do
visit root_path
click_on t('charts.navigation')
assert_current_path charts_path
end
test "renders Plotly chart panel" do
assert_selector '#measurements-charts .chart-panel', wait: 5
end
test "chart legend shows quantity name with unit" do
assert_text 'Weight (kg)', wait: 5
end
end

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