Compare commits

...

31 Commits

Author SHA1 Message Date
0e0cfeff10 Change Unit.multiplier type from decimal to float
Remove remnants related to BigDecimal.
2026-05-23 17:02:01 +02:00
1ab925f6fa Improve Units tests and remove test parallelization
Parallelization produces errors (#92)
2026-05-15 00:21:10 +02:00
83168092f1 Fix SQLite query compatibility inside WITH clause
SQLite3::SQLException: circular reference
2026-05-15 00:21:01 +02:00
852e6e7cea Fix test passing blank input into required field 2026-05-15 00:20:28 +02:00
0481b0f6f1 Allow Capybara test server IP in HostAuthorization 2026-05-07 20:53:18 +02:00
76b95f7c30 Set default :text type column limit 2026-05-07 18:20:06 +02:00
ca7edb99e1 Add Note 2026-05-07 18:17:30 +02:00
98044fc04a Remove empty test files 2026-05-05 23:41:08 +02:00
d3a34233b9 Set constraints on Readouts.user_id foreign key
Add Measurements table
2026-05-05 20:36:57 +02:00
4a36ffc8bf Move migration defaults to ActiveRecord migration strategy
Remove redundant defaults
2026-05-05 20:36:51 +02:00
78639b4c1e Upgrade Rails to 8.1.3 2026-05-05 20:35:00 +02:00
c4def9cd86 Filter :email field logging only in production env 2026-04-30 18:26:43 +02:00
3454d3052b Generate admin password on db:seed
Test admin account creation in db:seed
2026-04-30 18:23:00 +02:00
dedea0246f Run tests on multiple database configurations 2026-04-27 17:38:49 +02:00
3ac3a6f13c Drop Readout.value decimal type in favor of float 2026-04-25 18:20:16 +02:00
97e7bfedf7 Upgrade Rails to 8.0.5
Install sqlite3
Migrate Rails default settings from 7.2 to 8.0
2026-04-25 04:13:47 +02:00
a05767213c Fix test based on unavailable td.link selector 2026-04-25 04:13:47 +02:00
d002ae0f2c Clean up and improve items-table styling
Closes #9
2026-04-25 04:13:47 +02:00
f87aa9fd3d Improve Measurement form layout and styling 2026-04-25 04:13:47 +02:00
5ed066ad18 Unify border/outline parameters order 2026-03-06 01:42:32 +01:00
dde4e52f1b Fix form elements styling on hover 2026-03-06 01:33:23 +01:00
a9091d76a8 Merge styles of <a>.button/<button>/<input type="submit"> into .button
Remove flash button
Fix some multi-selector specificity differences
2026-03-04 17:13:11 +01:00
4175d31b9d Update and format comments 2026-03-03 01:31:44 +01:00
c659201904 Make [disabled] and [hidden] styles !important 2026-03-03 01:14:12 +01:00
83b064ef3c Merge recover password/resend confirmation forms into sign in/register
Closes #65, #66
2026-03-01 20:04:42 +01:00
ea8bff9b3d Bundle update to Rails 7.2.3, Devise 5 2026-02-26 02:39:11 +01:00
80130fb7d1 Allow cascade delete Unit/Quantity
Closes #32
2026-02-22 17:50:43 +01:00
1ba7d29441 Update tests to match labeled form changes 2026-02-22 00:55:21 +01:00
84945fa4b4 Simplify and improve labeled form 2026-02-22 00:53:18 +01:00
675eb0aad8 Optimize styles; clean up <fieldset> 2026-02-03 15:33:37 +01:00
bd1a664caa Measurement form based on select-styled <details> 2026-01-31 17:22:09 +01:00
109 changed files with 2279 additions and 1376 deletions

6
.gitignore vendored
View File

@@ -15,6 +15,9 @@
/config/master.key /config/master.key
/config/puma.rb /config/puma.rb
# Ignore test database.
/db/test.sqlite3
# Ignore all logfiles and tempfiles. # Ignore all logfiles and tempfiles.
/log/* /log/*
/tmp/* /tmp/*
@@ -30,7 +33,7 @@
/tmp/restart.txt /tmp/restart.txt
# Ignore user files # Ignore user files.
/.bash_history /.bash_history
/.byebug_history /.byebug_history
/.config /.config
@@ -38,6 +41,7 @@
/.lesshst /.lesshst
/.local /.local
/.mysql_history /.mysql_history
/.sqlite_history
/.ssh /.ssh
/.vim /.vim
/.viminfo /.viminfo

59
DESIGN.md Normal file
View File

@@ -0,0 +1,59 @@
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,
* if a decimal string with at most 15 significant digits is converted to
the IEEE 754 double-precision format, giving a normal number, and then
converted back to a decimal string with the same number of digits, the
final result should match the original string,
* if an IEEE 754 double-precision number is converted to a decimal
string with at least 17 significant digits, and then converted back to
double-precision representation, the final result must match the
original number,
* single precision floating point only guarntees 6 digits of precision,
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 majority of 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,
* at present, only normalized numbers are used and considered sufficient, in
order to avoid potential issues with the cross-platform compatibility of
subnormal (denormal) numbers,
* in the future, the IEEE 754 decimal64 data type may be considered once it is
supported by database engines.
### Database layer vs application layer data model constraints
* database constraints are the final guard against data integrity corruption,
* they should safeguard against data referential integrity loss under _all_
data (not schema) manipulation scenarios, including application level
logic errors and direct data manipulation (e.g. through `dbconsole`),
* application constraints can be as restrictive as database constraints or more,
but not less, as it doesn't serve any use case,
* proper application level constraints should prevent unhandled database
exception occurences, e.g `ActiveRecord::InvalidForeignKey` for operations
performed through Models (i.e. not `#delete_all` etc.)

View File

@@ -1,14 +1,18 @@
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.2" # NOTE: after updating Rails make sure that schema dump is not sorted:
# v8.1.3/activerecord/lib/active_record/schema_dumper.rb#L195
# Waiting for this change to be reverted/configuration setting added:
# https://github.com/rails/rails/pull/56842, https://github.com/rails/rails/pull/55414
gem "rails", "~> 8.1.3"
gem "sprockets-rails" gem "sprockets-rails"
gem "puma", "~> 6.0" gem "puma", "~> 6.0"
gem "sassc-rails" gem "sassc-rails"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
# TODO: select db gems automatically # TODO: select db gems automatically?
# database_config = ERB.new(File.read("config/database.yml")).result # database_config = ERB.new(File.read("config/database.yml")).result
# YAML.load(database_config, aliases: true).values.map { |env| env["adapter"] }.uniq # YAML.load(database_config, aliases: true).values.map { |env| env["adapter"] }.uniq
group :mysql, optional: true do group :mysql, optional: true do

View File

@@ -1,86 +1,89 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.2.2.1) action_text-trix (2.1.18)
actionpack (= 7.2.2.1) railties
activesupport (= 7.2.2.1) actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
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.2.1) actionmailbox (8.1.3)
actionpack (= 7.2.2.1) actionpack (= 8.1.3)
activejob (= 7.2.2.1) activejob (= 8.1.3)
activerecord (= 7.2.2.1) activerecord (= 8.1.3)
activestorage (= 7.2.2.1) activestorage (= 8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (7.2.2.1) actionmailer (8.1.3)
actionpack (= 7.2.2.1) actionpack (= 8.1.3)
actionview (= 7.2.2.1) actionview (= 8.1.3)
activejob (= 7.2.2.1) activejob (= 8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.2.2.1) actionpack (8.1.3)
actionview (= 7.2.2.1) actionview (= 8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc rack (>= 2.2.4)
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.2.1) actiontext (8.1.3)
actionpack (= 7.2.2.1) action_text-trix (~> 2.1.15)
activerecord (= 7.2.2.1) actionpack (= 8.1.3)
activestorage (= 7.2.2.1) activerecord (= 8.1.3)
activesupport (= 7.2.2.1) activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.2.2.1) actionview (8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
builder (~> 3.1) builder (~> 3.1)
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.2.1) activejob (8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.2.2.1) activemodel (8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
activerecord (7.2.2.1) activerecord (8.1.3)
activemodel (= 7.2.2.1) activemodel (= 8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.2.2.1) activestorage (8.1.3)
actionpack (= 7.2.2.1) actionpack (= 8.1.3)
activejob (= 7.2.2.1) activejob (= 8.1.3)
activerecord (= 7.2.2.1) activerecord (= 8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.2.2.1) activesupport (8.1.3)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
json
logger (>= 1.4.2) logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7) uri (>= 0.13.1)
public_suffix (>= 2.0.2, < 7.0) addressable (2.9.0)
base64 (0.2.0) public_suffix (>= 2.0.2, < 8.0)
bcrypt (3.1.20) base64 (0.3.0)
benchmark (0.4.0) bcrypt (3.1.22)
bigdecimal (3.1.9) bigdecimal (4.1.2)
bindex (0.8.1) bindex (0.8.1)
builder (3.3.0) builder (3.3.0)
byebug (12.0.0) byebug (13.0.0)
reline (>= 0.6.0)
capybara (3.40.0) capybara (3.40.0)
addressable addressable
matrix matrix
@@ -90,54 +93,61 @@ 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)
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.4.1) date (3.5.1)
devise (4.9.4) devise (5.0.3)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 7.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
drb (2.2.1) drb (2.2.3)
erb (6.0.4)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl) ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu) ffi (1.17.4-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl) ffi (1.17.4-arm-linux-musl)
ffi (1.17.2-arm64-darwin) ffi (1.17.4-arm64-darwin)
ffi (1.17.2-x86_64-darwin) ffi (1.17.4-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl) ffi (1.17.4-x86_64-linux-musl)
globalid (1.2.1) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.7) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (2.1.0) importmap-rails (2.2.3)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.0) io-console (0.8.2)
irb (1.15.2) irb (1.18.0)
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)
json (2.19.4)
logger (1.7.0) logger (1.7.0)
loofah (2.24.0) loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.0)
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.0.4) marcel (1.1.0)
matrix (0.4.2) matrix (0.4.3)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.5) minitest (6.0.6)
mysql2 (0.5.6) drb (~> 2.0)
net-imap (0.5.7) prism (~> 1.5)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.4)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -146,83 +156,93 @@ GEM
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.8-aarch64-linux-gnu) nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl) nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu) nokogiri (1.19.3-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl) nokogiri (1.19.3-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin) nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin) nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu) nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl) nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
pg (1.5.9) pg (1.6.3)
pp (0.6.2) pg (1.6.3-aarch64-linux)
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)
psych (5.2.3) prism (1.9.0)
psych (5.3.1)
date date
stringio stringio
public_suffix (6.0.1) public_suffix (7.0.5)
puma (6.6.0) puma (6.6.1)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.13) rack (3.2.6)
rack-session (2.1.0) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (7.2.2.1) rails (8.1.3)
actioncable (= 7.2.2.1) actioncable (= 8.1.3)
actionmailbox (= 7.2.2.1) actionmailbox (= 8.1.3)
actionmailer (= 7.2.2.1) actionmailer (= 8.1.3)
actionpack (= 7.2.2.1) actionpack (= 8.1.3)
actiontext (= 7.2.2.1) actiontext (= 8.1.3)
actionview (= 7.2.2.1) actionview (= 8.1.3)
activejob (= 7.2.2.1) activejob (= 8.1.3)
activemodel (= 7.2.2.1) activemodel (= 8.1.3)
activerecord (= 7.2.2.1) activerecord (= 8.1.3)
activestorage (= 7.2.2.1) activestorage (= 8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.2.2.1) railties (= 8.1.3)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
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.2.1) railties (8.1.3)
actionpack (= 7.2.2.1) actionpack (= 8.1.3)
activesupport (= 7.2.2.1) activesupport (= 8.1.3)
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.2.1) rake (13.4.2)
rdoc (6.13.1) rdoc (7.2.0)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.10.0) tsort
reline (0.6.1) regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.1.1) responders (3.2.0)
actionpack (>= 5.2) actionpack (>= 7.0)
railties (>= 5.2) railties (>= 7.0)
rexml (3.4.1) rexml (3.4.4)
rubyzip (2.4.1) rubyzip (3.3.0)
sassc (2.4.0) sassc (2.4.0)
ffi (~> 1.9) ffi (~> 1.9)
sassc-rails (2.1.2) sassc-rails (2.1.2)
@@ -232,11 +252,11 @@ GEM
sprockets-rails sprockets-rails
tilt tilt
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.31.0) selenium-webdriver (4.43.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, < 3.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sprockets (4.2.2) sprockets (4.2.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -246,39 +266,40 @@ GEM
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (2.7.3-aarch64-linux-gnu) sqlite3 (2.9.3-aarch64-linux-gnu)
sqlite3 (2.7.3-aarch64-linux-musl) sqlite3 (2.9.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu) sqlite3 (2.9.3-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl) sqlite3 (2.9.3-arm-linux-musl)
sqlite3 (2.7.3-arm64-darwin) sqlite3 (2.9.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin) sqlite3 (2.9.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu) sqlite3 (2.9.3-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl) sqlite3 (2.9.3-x86_64-linux-musl)
stringio (3.1.7) stringio (3.2.0)
thor (1.3.2) thor (1.5.0)
tilt (2.6.0) tilt (2.7.0)
timeout (0.4.3) timeout (0.6.1)
turbo-rails (2.0.13) 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)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.3.0)
actionview (>= 6.0.0) actionview (>= 8.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 8.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.7.7) websocket-driver (0.8.0)
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.2) zeitwerk (2.7.5)
PLATFORMS PLATFORMS
aarch64-linux-gnu aarch64-linux-gnu
@@ -298,7 +319,7 @@ DEPENDENCIES
mysql2 (~> 0.5) mysql2 (~> 0.5)
pg (~> 1.5) pg (~> 1.5)
puma (~> 6.0) puma (~> 6.0)
rails (~> 7.2.2) rails (~> 8.1.3)
sassc-rails sassc-rails
selenium-webdriver selenium-webdriver
sprockets-rails sprockets-rails

View File

@@ -16,14 +16,10 @@ environment, see the _Contributing_ section below.
* Server side: * Server side:
* Ruby interpreter, depending on the version of Rails used (see _Gemfile_), * Ruby interpreter, depending on the version of Rails used (see _Gemfile_),
* https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#ruby-versions * https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#ruby-versions
* database (e.g. MySQL >= 8.0) supporting: * database (e.g. SQLite, MySQL >= 8.0) supporting:
* recursive Common Table Expressions (CTE) for SELECT/UPDATE/DELETE, * recursive Common Table Expressions (CTE) for SELECT/UPDATE/DELETE,
* MariaDB does not support CTE for UPDATE/DELETE * MariaDB does not support CTE for UPDATE/DELETE
(https://jira.mariadb.org/browse/MDEV-18511) (https://jira.mariadb.org/browse/MDEV-18511)
* decimal datatype with precision of at least 30,
* SQLite3 _flexible typing_ decimal will work, but precision
will be limited to 16, making it practical mostly for testing
purposes
* for testing: browser as specified in _Client side_ requirements * for testing: browser as specified in _Client side_ requirements
* Client side: * Client side:
* browser (e.g. Firefox >= 121) supporting: * browser (e.g. Firefox >= 121) supporting:
@@ -89,14 +85,10 @@ Running
### Standalone Rails server + Apache proxy ### Standalone Rails server + Apache proxy
Customize Puma config template: Copy and customize Puma config template if required:
cp -a config/puma.rb.dist config/puma.rb cp -a config/puma.rb.dist config/puma.rb
and specify server IP/port, either with `port` or `bind`, e.g.:
bind 'tcp://0.0.0.0:3000'
#### (option 1) Start server manually #### (option 1) Start server manually
bundle exec rails s -e production bundle exec rails s -e production
@@ -123,9 +115,10 @@ Contributing
### Gems ### Gems
Apart from database adapter, install development and testing gems: Install development and testing gems, including at least MySQL and SQLite
database adapters:
bundle config --local with mysql development test bundle config --local with development test mysql sqlite
### Configuration ### Configuration
@@ -138,20 +131,29 @@ assets.
### Database ### Database
Grant database user privileges for development and test environments, Grant database user privileges for development and test environments. Example
possibly with different Ruby versions: below shows how to grant privileges to all databases which names start with
`fixinme-` on MySQL:
> mysql -p > mysql -p
mysql> create user `fixinme-dev`@localhost identified by '<some password>'; mysql> create user `fixinme-dev`@localhost identified by '<some password>';
mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost; mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost;
mysql> flush privileges; mysql> flush privileges;
Dumping development data before database reset:
mysqldump -h <address> -u <user> -p --no-create-info --no-tablespaces --complete-insert <database> > tmp/data.sql
### Development environment ### Development environment
Starting application server in development environment: Starting application server in development environment:
bundle exec rails s -e development bundle exec rails s -e development
Accessing database console when more than one test db is present:
bundle exec rails dbconsole -e test --db sqlite3
For running rake tasks, prepend command with environment: For running rake tasks, prepend command with environment:
RAILS_ENV=development bundle exec rails ... RAILS_ENV=development bundle exec rails ...
@@ -164,14 +166,22 @@ Tests need to be run from within toplevel application directory:
bundle exec rails test:system bundle exec rails test:system
* system test(s) with seed/test name specified: * system test(s) with seed or test name specified:
bundle exec rails test:system --seed 64690 --name test_add_unit bundle exec rails test:system --include test_add_unit --seed 64690
* all tests from one file, with optional seed: * all tests from one file, optionally with seed:
bundle exec rails test test/system/users_test.rb --seed 1234 bundle exec rails test test/system/users_test.rb --seed 1234
* system tests for selected database configuration (if multiple present):
bundle exec rails test:system --include /^test_sqlite3_/
* single system test for all database configurations (if multiple present):
bundle exec rails test:system --include /^test_\\w+_add_unit$/
### Icons ### Icons
Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/ Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/

View File

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

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 152 B

View File

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

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 278 B

View File

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

After

Width:  |  Height:  |  Size: 148 B

View File

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

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -15,6 +15,15 @@
*= 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;
@@ -46,49 +55,163 @@
:focus-visible { :focus-visible {
outline: none; outline: none;
} }
/* NOTE: move to higher priority layer instead of using !important?; add CSS
* @layer requirements in README */
/* TODO: collapse gaps around empty rows (`topside`) once possible [disabled] {
* https://github.com/w3c/csswg-drafts/issues/5813 */ border-color: var(--color-border-gray) !important;
body { color: var(--color-border-gray) !important;
display: grid; /* NOTE: cannot set cursor when `pointer-events: none`; can be fixed by setting
gap: 0.8em; * `cursor` on wrapping element.
grid-template-areas: cursor: not-allowed; */
"header header header" fill: var(--color-border-gray) !important;
"nav nav nav" pointer-events: none !important;
"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;
} }
/* Styles set `display` without distinguishing between [hidden] elements, making
* them visible. */
[hidden] {
display: none !important;
}
/* Color coding of input controls' background:
* blue - target for interaction with pointer,
* gray - target for interaction with keyboard,
* red - destructive, non-undoable action.
*/
/* TODO: merge selectors using :is() */
a,
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;
}
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>.
/* blue - target for interaction with pointer */ * `.link`: any other <a>.
/* gray - target for interaction with keyboard */ * `.tab`: tab-styled <a>.
/* TODO: remove non-font-size rems from buttons/inputs below */ */
a, .button,
button, .link,
input[type=submit] { .tab {
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
} }
.button, .button,
button,
input[type=submit],
.tab { .tab {
align-items: center; align-items: center;
color: var(--color-gray); color: var(--color-gray);
@@ -96,66 +219,35 @@ input[type=submit],
fill: var(--color-gray); fill: var(--color-gray);
font-weight: bold; font-weight: bold;
} }
.button, .button {
button, border: 1px solid var(--color-gray);
input[type=submit] { border-radius: 0.25em;
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.4em; padding: 0.6em 0.5em;
width: fit-content; width: fit-content;
} }
input:not([type=submit]):not([type=checkbox]), .link {
select, color: inherit;
textarea { text-decoration: underline 1px var(--color-border-gray);
padding: 0.2em 0.4em; text-underline-offset: 0.25em;
} }
.button, .auxiliary {
button, border-color: var(--color-nav-gray);
input, color: var(--color-nav-gray);
select, fill: var(--color-nav-gray);
textarea {
border: solid 1px var(--color-gray);
border-radius: 0.25em;
} }
fieldset, table .button {
textarea { border-color: var(--color-border-gray);
margin: 0 font-weight: normal;
} height: 100%;
.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; 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,
button:focus-visible, .tab:focus-visible,
input[type=submit]:focus-visible { .tab:hover {
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;
@@ -165,68 +257,41 @@ input[type=submit]:hover {
background-color: var(--color-red); background-color: var(--color-red);
border-color: var(--color-red); border-color: var(--color-red);
} }
.link:focus-visible {
input[type=checkbox] { text-decoration-color: var(--color-gray);
accent-color: var(--color-blue);
appearance: none;
-webkit-appearance: none;
display: flex;
height: 1.1rem;
margin: 0;
width: 1.1rem;
} }
input[type=checkbox]:checked { .link:hover {
appearance: checkbox; color: var(--color-blue);
-webkit-appearance: checkbox; text-decoration-color: var(--color-blue);
}
/* Hide spin buttons in input number fields */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
input::-webkit-outer-spin-button {
-webkit-appearance: none;
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;
@@ -235,37 +300,31 @@ header {
margin-inline-start: 4%; margin-inline-start: 4%;
} }
.navigation > .tab { .navigation > .tab {
border-bottom: solid 2px var(--color-nav-gray); border-bottom: 2px solid var(--color-nav-gray);
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
justify-content: center; justify-content: center;
padding-block: 0.3em; padding-block: 0.4em;
}
.navigation > .tab:hover,
.navigation > .tab:focus-visible {
background-color: var(--color-focus-gray);
} }
.navigation > .tab.active { .navigation > .tab.active {
border-bottom: solid 4px var(--color-blue); border-bottom: 4px solid 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 { .leftside-area {
grid-area: leftside; grid-area: leftside;
} }
.main { .main-area {
grid-area: main; grid-area: main;
} }
.rightside { .rightside-area {
grid-area: rightside; grid-area: rightside;
} }
.buttongrid { .buttongrid {
display: grid; display: grid;
gap: 0.4em; gap: 0.4em;
@@ -273,14 +332,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 { .tools-area {
grid-area: tools; grid-area: tools;
} }
#flashes { #flashes {
display: grid; display: grid;
gap: 0.2em; row-gap: 0.4em;
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;
@@ -296,281 +355,289 @@ 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.alert:before { .flash::before {
content: url('pictograms/alert-outline.svg'); filter: invert(1);
height: 1.4em; height: 1.4em;
margin: 0 0.5em; margin: 0 0.5em;
width: 1.4em; width: 1.4em;
} }
.flash.alert::before {
content: url('pictograms/alert-outline.svg');
}
.flash.alert { .flash.alert {
border-color: var(--color-red); border-color: var(--color-red);
background-color: var(--color-red); background-color: var(--color-red);
} }
.flash.notice:before { .flash.notice::before {
content: url('pictograms/check-circle-outline.svg'); content: url('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 > div { .flash svg {
grid-column: 2;
}
/* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
cursor: pointer; cursor: pointer;
font-size: 1.4em; fill: white;
font-weight: bold; height: 2.2em;
grid-column: 3;
opacity: 0.6; opacity: 0.6;
padding: 0.2em 0.4em; padding: 0.4em 0.5em;
width: 2.4em;
} }
.flash > button:hover { .flash svg:hover {
opacity: 1; opacity: 1;
} }
/* TODO: Update styling, including rem removal. */ .labeled-form {
form table { align-items: center;
border-spacing: 0.8rem; display: grid;
gap: 0.9em 1.1em;
grid-template-columns: 1fr minmax(max-content, 0.5fr) 1fr;
} }
form tr td:first-child { .labeled-form label {
color: var(--color-gray); color: var(--color-gray);
font-size: 0.9rem; font-size: 0.9rem;
padding-right: 0.25rem; grid-column: 1;
text-align: right; text-align: right;
white-space: nowrap;
} }
form label.required { .labeled-form label.required {
font-weight: bold; font-weight: bold;
} }
form label.error, /* Don't style `label.error + input` if case already covered by `input:invalid`. */
form td.error::after { .labeled-form label.error {
color: var(--color-red); color: var(--color-red);
} }
form td.error { .labeled-form em {
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;
} }
form input[type=submit] { .labeled-form input {
float: none; grid-column: 2;
}
.labeled-form input[type=submit] {
font-size: 1rem; font-size: 1rem;
margin: 1.5rem auto 0 auto; margin: 1em auto 0 auto;
padding: 0.75rem; padding: 0.75em;
}
.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;
}
.tabular-form table td {
border: none;
padding-inline-start: 0.4em;
vertical-align: middle;
}
.tabular-form table td:first-child {
padding-inline-start: 0;
}
.tabular-form table td:last-child {
padding-inline-end: 0;
}
.tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0;
} }
table.items { .items-table {
border-spacing: 0; border-spacing: 0;
border: solid 1px var(--color-border-gray); border: 1px solid 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;
} }
table.items thead { .items-table thead {
font-size: 0.8rem; font-size: 0.8rem;
} }
table.items thead, .items-table thead,
table.items tbody tr:hover { .items-table tbody tr:hover {
background-color: var(--color-focus-gray); background-color: var(--color-focus-gray);
} }
table.items th { .items-table th {
padding-block: 0.75em; padding: 0.75em 0 0.75em 1em;
text-align: center; text-align: center;
} }
table.items th, .items-table th:last-child {
table.items td {
padding-inline: 1em 0;
}
/* For <a> to fill <td> completely, we use an ::after pseudoelement. */
table.items td.link {
padding: 0;
position: relative;
}
table.items td.link a {
color: inherit;
font: inherit;
}
table.items td.link a::after {
content: '';
inset: 0;
position: absolute;
}
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; padding-inline-end: 0.4em;
} }
table.items td:last-child { .items-table td {
border-top: 1px solid var(--color-border-gray);
height: 2.4em;
padding: 0.1em 0 0.1em calc(1em + var(--depth) * 0.8em);
}
.items-table td:last-child {
padding-inline-end: 0.1em; padding-inline-end: 0.1em;
} }
table.items td { .items-table :is(form, input, select, textarea):only-child {
border-top: solid 1px var(--color-border-gray); margin-inline-start: calc(-0.4em - 0.9px);
height: 2.4em;
padding-block: 0.1em;
} }
table.items .actions { /* For <a> to fill table cell completely, we use an `::after` pseudoelement. */
display: flex; /* TODO: expand to whole row? will require adjusting z-index on inputs/buttons */
.items-table td:has(> .link) {
position: relative;
}
.items-table .link::after {
content: '';
inset: -1px 0 0 0;
position: absolute;
}
.items-table .flex {
gap: 0.4em; gap: 0.4em;
justify-content: end; justify-content: end;
} }
table.items .actions.centered { .items-table .dropzone {
justify-content: center;
}
table.items tr.dropzone {
position: relative; position: relative;
} }
table.items tr.dropzone::after { .items-table .dropzone::after {
content: ''; content: '';
inset: 1px 0 0 0; inset: 1px 0 0 0;
position: absolute; position: absolute;
outline: dashed 2px var(--color-blue); outline: 2px dashed 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);
} }
table.items td.handle { .items-table .handle {
cursor: move; cursor: grab;
} }
table.items tr.form td { .items-table .form td {
vertical-align: top; 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-table-gray); fill: var(--color-gray);
} }
table.items td.hint { .items-table td:has(> svg:only-child) {
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;
}
.centered { .center {
margin: 0 auto; margin: 0 auto;
} }
.extendedright { .hexpand {
margin-right: auto; width: 100%;
} }
.hflex { .flex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
} }
.vflex { .flex.reverse {
display: flex; flex-direction: row-reverse;
gap: 0.8em; }
.flex.vertical {
flex-direction: column; flex-direction: column;
} }
[disabled] { .hint {
border-color: var(--color-border-gray) !important; color: var(--color-table-gray);
color: var(--color-border-gray) !important; font-style: italic;
cursor: not-allowed; font-size: 0.9rem;
fill: var(--color-border-gray) !important; text-align: center;
pointer-events: none;
} }
.unwrappable { .hmin50 {
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;
}

View File

@@ -25,6 +25,18 @@ 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,36 +1,11 @@
class MeasurementsController < ApplicationController class MeasurementsController < ApplicationController
before_action :find_quantity, only: [:new, :discard]
before_action :find_prev_quantities, only: [:new, :discard]
def index def index
@quantities = current_user.quantities.ordered @measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
end end
def new def new
quantities = @quantities = current_user.quantities.ordered
case params[:scope]
when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
quantities -= @prev_quantities
@readouts = current_user.readouts.build(quantities.map { |q| {quantity: q} })
@units = current_user.units.ordered
all_quantities = @prev_quantities + quantities
@common_ancestor = current_user.quantities
.common_ancestors(all_quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@common_ancestor = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end end
def create def create
@@ -38,15 +13,4 @@ class MeasurementsController < ApplicationController
def destroy def destroy
end end
private
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
def find_prev_quantities
prev_quantity_ids = params[:readouts]&.map { |r| r[:quantity_id] } || []
@prev_quantities = current_user.quantities.find(prev_quantity_ids)
end
end end

View File

@@ -0,0 +1,39 @@
class ReadoutsController < ApplicationController
before_action :find_quantities, only: [:new]
before_action :find_quantity, only: [:discard]
before_action :find_prev_quantities, only: [:new, :discard]
def new
@quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = @quantities.map { |q| q.readouts.build }
@user_units = current_user.units.ordered
quantities = @prev_quantities + @quantities
@superquantity = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@superquantity = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end
private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
def find_prev_quantities
prev_quantity_ids = params[:readouts]&.map { |r| r[:quantity_id] } || []
@prev_quantities = current_user.quantities.find(prev_quantity_ids)
end
end

View File

@@ -17,7 +17,7 @@ class UnitsController < ApplicationController
end end
def create def create
@unit = current_user.units.new(unit_params) @unit = current_user.units.new(params.expect(Unit::ATTRIBUTES))
if @unit.save if @unit.save
@before = @unit.successive @before = @unit.successive
flash.now[:notice] = t('.success', unit: @unit) flash.now[:notice] = t('.success', unit: @unit)
@@ -30,7 +30,7 @@ class UnitsController < ApplicationController
end end
def update def update
if @unit.update(unit_params.except(:base_id)) if @unit.update(params.except(:base_id).expect(Unit::ATTRIBUTES))
flash.now[:notice] = t('.success', unit: @unit) flash.now[:notice] = t('.success', unit: @unit)
else else
render :edit render :edit
@@ -40,11 +40,11 @@ class UnitsController < ApplicationController
# TODO: Avoid double table width change by first un-hiding table header, # TODO: Avoid double table width change by first un-hiding table header,
# then displaying index, e.g. by re-displaying header in index # then displaying index, e.g. by re-displaying header in index
def rebase def rebase
permitted = params.require(:unit).permit(:base_id) unit_params = params.expect(unit: :base_id)
permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1 unit_params.merge!(multiplier: 1.0) if unit_params[:base_id].blank?
@previous_base = @unit.base @previous_base = @unit.base
@unit.update!(permitted) @unit.update!(unit_params)
@before = @unit.successive @before = @unit.successive
if @unit.multiplier_previously_changed? if @unit.multiplier_previously_changed?
@@ -59,10 +59,6 @@ class UnitsController < ApplicationController
private private
def unit_params
params.require(:unit).permit(Unit::ATTRIBUTES)
end
def find_unit def find_unit
@unit = current_user.units.find_by!(id: params[:id]) @unit = current_user.units.find_by!(id: params[:id])
end end

View File

@@ -1,5 +1,8 @@
class RegistrationsController < Devise::RegistrationsController class User::ProfilesController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy] def 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 registrations. :admin # manage their accounts by themselves through profiles. :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,59 +1,85 @@
module ApplicationHelper module ApplicationHelper
# TODO: replace legacy content_tag with tag.tagname class LabeledFormBuilder < ActionView::Helpers::FormBuilder
class LabelledFormBuilder < ActionView::Helpers::FormBuilder (field_helpers - [:label, :hidden_field]).each do |selector|
(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 = {})
labelled_row_for(method, options) { super } labeled_field_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 = {})
labelled_row_for(method, options) { super } labeled_field_for(method, options) { super }
end end
def submit(value, options = {}) def submit(value = nil, options = {})
@template.content_tag :tr do value, options = nil, value if value.is_a?(Hash)
@template.content_tag :td, super, colspan: 2 options[:class] = @template.class_names('button', options[:class])
end super
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 labelled_row_for(method, options) def labeled_field_for(method, options)
@template.content_tag :tr do field = if options.delete(:readonly) then
@template.content_tag(:td, label_for(method, options), class: "unwrappable") + value = object.public_send(method)
@template.content_tag(:td, options.delete(:readonly) ? @object.public_send(method) : yield, value = @template.l(value) if value.respond_to?(:strftime)
@object&.errors[method].present? ? value ||= options[:placeholder]
{class: "error", data: {content: @object&.errors.delete(method).join(" and ")}} : else
{}) 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)
label + (@template.tag(:br) + @template.content_tag(:em, hint) if hint) handler = {missing_interpolation_argument_handler: method(:interpolation_missing)}
# 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
def interpolation_missing(key, values, string)
@template.instance_values[key.to_s] || deep_translate(key, **values)
end
# 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
end end
def labelled_form_for(record, options = {}, &block) def labeled_form_for(record, options = {}, &block)
options = options.deep_merge(builder: LabelledFormBuilder, data: {turbo: false}) extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}}
form_for(record, **options) { |f| f.form_for(&block) } form_for(record, **merge_attributes(options, extra_options), &block)
end end
class TabularFormBuilder < ActionView::Helpers::FormBuilder class TabularFormBuilder < ActionView::Helpers::FormBuilder
@@ -75,22 +101,33 @@ module ApplicationHelper
end end
def number_field(method, options = {}) def number_field(method, options = {})
value = object.public_send(method) attr_type = object.type_for_attribute(method)
if value.is_a?(BigDecimal) case attr_type.type
options[:value] = value.to_scientific when :float, :double
type = object.class.type_for_attribute(method) options[:value] = object.public_send(method)&.to_scientific
options[:step] ||= BigDecimal(10).power(-type.scale) options[:step] ||= :any
options[:max] ||= BigDecimal(10).power(type.precision - type.scale) - options[:min] ||= Float::MIN_15
options[:step] options[:max] ||= Float::MAX_15
options[:min] = options[:min] == :step ? options[:step] : options[:min] # Longest possible number (written not using exponent):
options[:min] ||= -options[:max] # sign (1), leading 0 (1), dot (1), exponent 0s (307), digits (15).
# This is only upper bound, which cannot guarantee the number won't fall
# out of range.
# TODO: add `[pattern]` to limit precision and (possibly) replace `[maxlength]`?
# NOTE: `[pattern]` is unavailable on `input[type=number]` and `[min]/[max]` is
# unavailable on `input[type=text]`.
options[:maxlength] ||= 3 + Float::MIN_10_EXP + Float::DIG
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 # #button does not use #objectify_options/@default_options
options.merge!(@options.slice(:form)) value, options = nil, value if value.is_a?(Hash)
options = options.merge(
@default_options.slice(:form),
class: @template.class_names('button', options[:class])
)
super super
end end
@@ -111,19 +148,22 @@ 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)
options.merge!(builder: TabularFormBuilder, skip_default_ids: true) extra_options = {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)
options = options.deep_merge(builder: TabularFormBuilder, extra_options = {builder: TabularFormBuilder, class: 'tabular-form',
html: {autocomplete: 'off'}) html: {autocomplete: 'off'}}
form_with(**options, &block) form_with(**merge_attributes(options, extra_options), &block)
end end
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
svg_tag = content_tag :svg, options do label, options = nil, label if label.is_a? Hash
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)
@@ -134,6 +174,7 @@ 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],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin], ['users', 'account-multiple-outline', :admin],
] ]
@@ -171,21 +212,26 @@ 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)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES if current_page?(options) if current_page?(options, method: [:get, :post])
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)
flash[:alert] ||= [] # Conversion of flash to Array only required because of Devise
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages } 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.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1, tag.span(sanitize(message)) +
onclick: "this.parentElement.remove();") svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"})
end end
end end
end.join.html_safe end.join.html_safe
@@ -204,8 +250,6 @@ 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',
@@ -216,10 +260,18 @@ module ApplicationHelper
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');" html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end end
if type == :link && !(html_options[:onclick] || html_options.dig(:data, :turbo_stream)) link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream)
name += '...' name = name.to_s
end name += '...' if type == :link && !link_is_local
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,2 +1,11 @@
module QuantitiesHelper module QuantitiesHelper
def quantities_check_boxes(quantities)
# Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
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

View File

@@ -9,31 +9,82 @@ function showPage(event) {
} }
document.addEventListener('turbo:load', showPage) document.addEventListener('turbo:load', showPage)
function detailsChange(event) {
var target = event.currentTarget
var count = target.querySelectorAll('input:checked:not([disabled])').length
var span = target.querySelector('summary > span')
var button = target.querySelector('button')
if (count > 0) {
span.textContent = count + ' selected';
Turbo.StreamElement.prototype.enableElement(button)
} else {
span.textContent = span.getAttribute('data-prompt')
Turbo.StreamElement.prototype.disableElement(button)
}
}
window.detailsChange = detailsChange
/* Close open <details> when focus lost */
function detailsClose(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != event.currentTarget) {
event.currentTarget.removeAttribute("open")
}
}
window.detailsClose = detailsClose
window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
});
function formValidate(event) {
var id = event.submitter.getAttribute("data-validate")
if (!id) return;
var input = document.getElementById(id)
if (!input.checkValidity()) {
input.reportValidity()
event.preventDefault()
}
}
window.formValidate = formValidate
/* Turbo stream actions */ /* Turbo 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")
// 'tabindex' is not used explicitly, so removing it is safe // Assume '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 */
@@ -54,28 +105,63 @@ Turbo.StreamActions.close_form = function() {
}) })
} }
Turbo.StreamActions.unselect = function() {
this.targetElements.forEach((e) => {
e.checked = false
this.enableElement(e)
})
}
/* Items table drag and drop support */ function formProcessKey(event) {
function processKey(event) { switch (event.key) {
if (event.key == "Escape") { case "Escape":
event.currentTarget.querySelector("a[name=cancel]").click(); event.currentTarget.querySelector("a[name=cancel]").click()
break
case "Enter":
event.currentTarget.querySelector("button[name=button]").click()
event.preventDefault()
break
} }
} }
window.processKey = processKey; window.formProcessKey = formProcessKey
var lastEnterTime; function detailsProcessKey(event) {
function dragStart(event) { // TODO: up/down arrows to move focus to prev/next line
lastEnterTime = event.timeStamp; switch (event.key) {
var row = event.currentTarget; case "Escape":
row.closest("table").querySelectorAll("thead tr").forEach((tr) => { if (event.currentTarget.hasAttribute("open")) {
tr.toggleAttribute("hidden"); event.currentTarget.removeAttribute("open")
}); event.stopPropagation()
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path")); }
var rowRectangle = row.getBoundingClientRect(); break
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top); case "Enter":
event.dataTransfer.dropEffect = "move"; var button = event.currentTarget.querySelector("button:not([disabled])")
if (button) {
button.click()
// Autofocus won't be respected unless target is blurred
event.target.blur()
event.preventDefault()
event.stopPropagation()
}
break
}
} }
window.dragStart = dragStart; window.detailsProcessKey = detailsProcessKey;
/* Items table drag and drop support */
var lastEnterTime
function dragStart(event) {
lastEnterTime = event.timeStamp
var row = event.currentTarget
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden")
})
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"))
var rowRectangle = row.getBoundingClientRect()
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"
}
window.dragStart = dragStart
/* /*
* Drag tracking assumptions (based on FF 122.0 experience): * Drag tracking assumptions (based on FF 122.0 experience):
@@ -87,44 +173,44 @@ window.dragStart = dragStart;
* and outside. This should probably be fixed in browser, than patched here. * and outside. This should probably be fixed in browser, than patched here.
*/ */
function dragEnter(event) { function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id); //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
dragLeave(event); dragLeave(event)
lastEnterTime = event.timeStamp; lastEnterTime = event.timeStamp
const id = event.currentTarget.getAttribute("data-drop-id"); const id = event.currentTarget.getAttribute("data-drop-id")
document.getElementById(id).classList.add("dropzone"); document.getElementById(id).classList.add("dropzone")
} }
window.dragEnter = dragEnter; window.dragEnter = dragEnter
function dragOver(event) { function dragOver(event) {
event.preventDefault(); event.preventDefault()
} }
window.dragOver = dragOver; window.dragOver = dragOver
function dragLeave(event) { function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id); //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
// Leave has been accounted for by Enter at the same timestamp, processed earlier // Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return; if (event.timeStamp <= lastEnterTime) return
event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => { event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
tr.classList.remove("dropzone"); tr.classList.remove("dropzone")
}); })
} }
window.dragLeave = dragLeave; window.dragLeave = dragLeave
function dragEnd(event) { function dragEnd(event) {
dragLeave(event); dragLeave(event)
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => { event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden"); tr.toggleAttribute("hidden")
}); })
} }
window.dragEnd = dragEnd; window.dragEnd = dragEnd
function drop(event) { function drop(event) {
event.preventDefault(); event.preventDefault()
var params = new URLSearchParams(); var params = new URLSearchParams()
var id_param = event.currentTarget.getAttribute("data-drop-id-param"); var id_param = event.currentTarget.getAttribute("data-drop-id-param")
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop(); var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop()
params.append(id_param, id); params.append(id_param, id)
fetch(event.dataTransfer.getData("text/plain"), { fetch(event.dataTransfer.getData("text/plain"), {
body: params, body: params,
@@ -138,4 +224,4 @@ function drop(event) {
.then(response => response.text()) .then(response => response.text())
.then(html => Turbo.renderStreamMessage(html)) .then(html => Turbo.renderStreamMessage(html))
} }
window.drop = drop; window.drop = drop

View File

@@ -0,0 +1,3 @@
class Measurement
include ActiveModel::Model
end

3
app/models/note.rb Normal file
View File

@@ -0,0 +1,3 @@
class Note < ApplicationRecord
ATTRIBUTES = [:text]
end

View File

@@ -6,6 +6,7 @@ class Quantity < ApplicationRecord
belongs_to :parent, optional: true, class_name: "Quantity" belongs_to :parent, optional: true, class_name: "Quantity"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity", has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error inverse_of: :parent, dependent: :restrict_with_error
has_many :readouts, dependent: :restrict_with_error
validate if: ->{ parent.present? } do validate if: ->{ parent.present? } do
errors.add(:parent, :user_mismatch) unless user_id == parent.user_id errors.add(:parent, :user_mismatch) unless user_id == parent.user_id
@@ -100,6 +101,11 @@ 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

@@ -4,4 +4,6 @@ class Readout < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :quantity belongs_to :quantity
belongs_to :unit belongs_to :unit
# TODO: validate quantity.user_id == unit.user_id != NULL
end end

View File

@@ -1,9 +1,10 @@
class Unit < ApplicationRecord class Unit < ApplicationRecord
ATTRIBUTES = [:symbol, :description, :multiplier, :base_id] ATTRIBUTES = {unit: [:symbol, :description, :multiplier, :base_id]}
belongs_to :user, optional: true belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit" belongs_to :base, optional: true, class_name: "Unit"
has_many :subunits, class_name: "Unit", inverse_of: :base, dependent: :restrict_with_error has_many :subunits, class_name: "Unit", inverse_of: :base,
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
@@ -13,8 +14,8 @@ class Unit < ApplicationRecord
validates :symbol, presence: true, uniqueness: {scope: :user_id}, validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit} length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit} validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base validates :multiplier, numericality: {equal_to: 1.0}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base validates :multiplier, numericality: {greater_than: 0.0}, if: :base
scope :defaults, ->{ where(user: nil) } scope :defaults, ->{ where(user: nil) }
scope :defaults_diff, ->{ scope :defaults_diff, ->{
@@ -76,16 +77,14 @@ class Unit < ApplicationRecord
.from(units).group(:base_id, :symbol) .from(units).group(:base_id, :symbol)
} }
scope :ordered, ->{ scope :ordered, ->{
left_outer_joins(:base).order(ordering) left_outer_joins(:base).order([
arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil),
arel_table[:multiplier],
arel_table[:symbol]
])
} }
def self.ordering
[arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil),
:multiplier,
:symbol]
end
before_destroy do before_destroy do
# TODO: disallow destruction if any object depends on this unit # TODO: disallow destruction if any object depends on this unit
nil nil
@@ -103,21 +102,21 @@ class Unit < ApplicationRecord
user_id.nil? user_id.nil?
end end
# Should only by invoked on Units returned from #defaults_diff which are #portable # Should only by invoked on Units returned from #defaults_diff which are #portable.
def port!(recipient) def port!(recipient)
recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient) recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient)
params = slice(ATTRIBUTES - [:symbol, :base_id]) params = slice(ATTRIBUTES[:unit] - [:symbol, :base_id])
Unit.find_or_initialize_by(user: recipient, symbol: symbol) Unit.find_or_initialize_by(user: recipient, symbol: symbol)
.update!(base: recipient_base, **params) .update!(base: recipient_base, **params)
end end
def successive def successive
units = Unit.arel_table units = Unit.arel_table
lead = Arel::Nodes::NamedFunction.new('LAG', [units[:id]]) Unit.with(units_with_lag: user.units.left_outer_joins(:base).select(
window = Arel::Nodes::Window.new.order(*Unit.ordering) units[Arel.star],
lag_id = lead.over(window).as('lag_id') Arel::Nodes::NamedFunction.new('LAG', [units[:id]])
Unit.with( .over(Arel::Nodes::Window.new.order(Unit.ordered.order_values)).as('lag_id')
units: user.units.left_outer_joins(:base).select(units[Arel.star], lag_id) )).from(Arel::Table.new(:units_with_lag).as(:units))
).where(units[:lag_id].eq(id)).first .where(units[:lag_id].eq(id)).take
end end
end end

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: :destroy has_many :readouts, dependent: :delete_all
accepts_nested_attributes_for :readouts accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :destroy has_many :quantities, dependent: :delete_all
has_many :units, dependent: :destroy has_many :units, dependent: :delete_all
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

@@ -5,7 +5,7 @@
</td> </td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="actions"> <td class="flex">
<% 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,16 +1,17 @@
<div class="rightside buttongrid"> <div class="rightside-area 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, class: 'tools' %> <%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path,
class: 'tools-area' %>
</div> </div>
<table class="main items"> <table class="main-area items-table">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th> <th><%= Unit.human_attribute_name(:symbol) %></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

@@ -23,10 +23,10 @@
</head> </head>
<body> <body>
<header class="hflex"> <header class="flex">
<%= 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: "extendedright" %> class: "rextend" %>
<% 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) %>
@@ -47,7 +47,7 @@
<%= render_flash_messages %> <%= render_flash_messages %>
</div> </div>
<%# Allow overwriting/clearing navigation menu for some views %> <%# Allows 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 +1,45 @@
<% @readouts.each do |readout| %> <%= tabular_form_with model: Measurement.new, id: :measurement_form,
<%= tabular_fields_for 'readouts[]', readout do |form| %> class: 'topside-area flex vertical center',
<%- tag.tr id: dom_id(readout.quantity, :new, :readout), html: {onkeydown: 'formProcessKey(event)'} do |form| %>
onkeydown: 'processKey(event)' do %>
<%= tag.td id: dom_id(readout.quantity, nil, :pathname) do %>
<%= readout.quantity.relative_pathname(@common_ancestor) %>
<%end%>
<td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %>
</td>
<td>
<%= form.select :unit_id, options_from_collection_for_select(
@units, :id, ->(u){ sanitize('&emsp;'*(u.base_id ? 1 : 0) + u.symbol) }
) %>
</td>
<td class="actions"> <table class="items-table center">
<%= image_button_tag '', 'delete-outline', class: 'dangerous', <tbody id="readouts">
formaction: discard_new_measurement_path(readout.quantity), <%= tabular_fields_for @measurement do |form| %>
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %> <tr class="italic">
<%= form.hidden_field :quantity_id %> <td class="hexpand hmin50"><%= t '.taken_at_html' %></td>
</td> <td colspan="3" class="ralign">
<% end %> <%= form.datetime_field :taken_at, required: true %>
<% end %> </td>
</tr>
<% end %>
</tbody>
</table>
<%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<%= 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} %>
</summary>
<ul><%= 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: 'auxiliary dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %> <% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>

View File

@@ -1,2 +1,4 @@
<%= turbo_stream.update :new_readouts_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,16 +0,0 @@
<%= tabular_fields_for Readout.new do |form| %>
<fieldset>
<legend>
<%= tag.span id: :new_readouts_form_legend %>
<%= image_link_to '', "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</legend>
<table class="items centered">
<tbody id="readouts">
<tr id="new_readouts_actions">
<td colspan="4"><div class="actions centered"><%= form.button %></div></td>
</tr>
</tbody>
</table>
</fieldset>
<% end %>

View File

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

View File

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

View File

@@ -1,20 +1,14 @@
<div class="topside vflex"> <%# TODO: show hint when no quantities/units defined %>
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%# TODO: show hint when no quantities/units defined %> <%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
<%= tabular_form_with url: new_measurement_path, id: :new_measurement_link, onclick: 'this.blur();',
html: {id: :new_readouts_form} do |f| %> data: {turbo_stream: true} %>
<% end %>
<div class="hflex">
<%= select_tag :id, options_from_collection_for_select(
@quantities, :id, ->(q){ sanitize('&emsp;' * q.depth + q.name) }
), form: :new_readouts_form %>
<% common_options = {form: :new_readouts_form, formmethod: :get,
formnovalidate: true, data: {turbo_stream: true}} %>
<%= image_button_tag t('.new_quantity'), 'plus-outline', **common_options -%>
<%= image_button_tag t('.new_children'), 'plus-multiple-outline',
formaction: new_measurement_path(:children), **common_options -%>
<%= image_button_tag t('.new_subtree'), 'plus-multiple-outline',
formaction: new_measurement_path(:subtree), **common_options -%>
</div>
<% end %> <% end %>
</div> </div>
<table class="main-area">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>
</table>

View File

@@ -1,9 +1,5 @@
<%= turbo_stream.update :new_readouts_form do %> <%= turbo_stream.disable :new_measurement_link -%>
<%= render partial: 'form_frame' %> <%= turbo_stream.hide :no_items -%>
<% end if @prev_quantities.empty? %> <%= turbo_stream.append_all 'body' do %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.before :new_readouts_actions do %>
<%= render partial: 'form' %> <%= render partial: 'form' %>
<% end %> <% end %>

View File

@@ -1,5 +1,5 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %> <%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "processKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %> data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>"> <td style="--depth:<%= @quantity.depth %>">
@@ -9,7 +9,7 @@
<%= form.text_area :description, cols: 30, rows: 1, escape: false %> <%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td> </td>
<td class="actions"> <td class="flex">
<%= 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

@@ -5,14 +5,14 @@
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity), data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %> drop_id_param: "quantity[parent_id]"} do %>
<td class="link" style="--depth:<%= quantity.depth %>"> <td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), onclick: 'this.blur();', <%= link_to quantity, edit_quantity_path(quantity), class: 'link',
data: {turbo_stream: true} %> onclick: 'this.blur();', data: {turbo_stream: true} %>
</td> </td>
<td><%= quantity.description %></td> <td><%= quantity.description %></td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="actions"> <td class="flex">
<%= 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,19 +1,21 @@
<div class="rightside buttongrid"> <div class="rightside-area 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();', data: {turbo_stream: true} %> id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
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' %> class: 'tools-area' %>
</div> </div>
<%= tag.div class: 'main', id: :quantity_form %> <%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main items"> <table class="main-area items-table">
<thead> <thead>
<tr> <tr>
<th><%= Quantity.human_attribute_name(:name).capitalize %></th> <th><%= Quantity.human_attribute_name(:name) %></th>
<th><%= Quantity.human_attribute_name(:description).capitalize %></th> <th class="hexpand"><%= Quantity.human_attribute_name(:description) %></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>

View File

@@ -0,0 +1,24 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<td>
<%# TODO: add grayed readout index (in separate column?) %>
<%= readout.quantity.relative_pathname(@superquantity) %>
<%= form.hidden_field :quantity_id %>
</td>
<td>
<%= form.number_field :value, required: true, autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: '', disabled: '', selected: ''}, required: true %>
</td>
<td class="flex">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<% end %>
<% end %>

View File

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

View File

@@ -0,0 +1,4 @@
<%= 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

@@ -0,0 +1,8 @@
<% @readouts.each do |r| %>
<%= turbo_stream.disable dom_id(r.quantity) %>
<% end %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %>
<%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>

View File

@@ -1,5 +1,5 @@
<%= tabular_fields_for @unit, form: form_tag do |form| %> <%= tabular_fields_for @unit, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "processKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %> data: {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 %>">
@@ -8,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 class="number"> <td>
<%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %> <%= form.number_field :multiplier, required: true if @unit.base_id? %>
</td> </td>
<td class="actions"> <td class="flex">
<%= 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

@@ -6,14 +6,15 @@
drop_id: dom_id(unit.base || unit), drop_id: dom_id(unit.base || unit),
drop_id_param: "unit[base_id]"} do %> drop_id_param: "unit[base_id]"} do %>
<td class="link" style="--depth:<%= unit.base_id? ? 1 : 0 %>"> <td style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), onclick: 'this.blur();', data: {turbo_stream: true} %> <%= link_to unit, edit_unit_path(unit), class: 'link', onclick: 'this.blur();',
data: {turbo_stream: true} %>
</td> </td>
<td><%= unit.description %></td> <td><%= unit.description %></td>
<td class="number"><%= unit.multiplier.to_html %></td> <td class="ralign"><%= unit.multiplier.to_html if unit.base_id? %></td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="actions"> <td class="flex">
<% 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,19 +1,21 @@
<div class="rightside buttongrid"> <div class="rightside-area 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, class: 'tools' %> <%= image_link_to t('.import_units'), 'download-outline', default_units_path,
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 items"> <table class="main-area items-table">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th> <th><%= Unit.human_attribute_name(:symbol) %></th>
<th><%= User.human_attribute_name(:description).capitalize %></th> <th class="hexpand"><%= Unit.human_attribute_name(:description) %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th> <th><%= Unit.human_attribute_name(:multiplier) %></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>

View File

@@ -1,4 +1,4 @@
<%= turbo_stream.remove @unit %> <%= turbo_stream.remove @unit %>
<%= turbo_stream.replace @previous_base if @previous_base %> <%= turbo_stream.replace @previous_base if @previous_base %>
<%= turbo_stream.replace @unit.base if @unit.base_id? && (@previous_base.id != @unit.base_id) %> <%= turbo_stream.replace @unit.base if @unit.base_id? && (@previous_base&.id != @unit.base_id) %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %> <%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>

View File

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

View File

@@ -1,8 +0,0 @@
<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,36 +1,34 @@
<table class="main items" id="users"> <table class="main-area items-table" id="users">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:email).capitalize %></th> <th><%= User.human_attribute_name(:email) %></th>
<th><%= User.human_attribute_name(:status).capitalize %></th> <th><%= User.human_attribute_name(:status) %></th>
<th><%= User.human_attribute_name(:confirmed_at).capitalize %></th> <th><%= User.human_attribute_name(:confirmed_at) %></th>
<th> <th><%= User.human_attribute_name(:created_at) %>&nbsp;<sup>(UTC)</sup></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 class="link"><%= link_to user, user_path(user) %></td> <td><%= link_to user, user_path(user), class: 'link' %></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 class="svg"> <td>
<%= 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><%= user.created_at.to_fs(:db_without_sec) %></td> <td><%= l user.created_at, format: :without_tz %></td>
<td class="actions"> <td class="flex">
<% if allow_disguise?(user) %> <% if allow_disguise?(user) %>
<%= image_link_to t(".disguise"), "incognito", disguise_user_path(user) %> <%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %>
<% 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

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

View File

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

View File

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

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

@@ -0,0 +1,16 @@
<%= labeled_form_for resource, url: user_registration_path,
html: {class: 'main-area', onsubmit: 'formValidate(event)'} 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

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

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

View File

@@ -1,19 +1,18 @@
<% content_for :navigation, flush: true do %> <% content_for :navigation, flush: true do %>
<div class="left"> <%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)), users_path,
<%= image_link_to t(:back), "arrow-left-bold-outline", users_path %> class: 'tab' %>
</div>
<% end %> <% end %>
<%= labelled_form_for @user do |f| %> <%= labeled_form_for @user, html: {class: 'main-area'} do |f| %>
<%= f.email_field :email, readonly: true %> <%= f.email_field :email, readonly: true %>
<% if f.object.pending_reconfirmation? %> <% if @user.pending_reconfirmation? %>
<%= f.email_field :unconfirmed_email, readonly: true, <%= f.email_field :unconfirmed_email, readonly: true,
hint: t("users.registrations.edit.unconfirmed_email_hint", confirmation_sent_at: l(@user.confirmation_sent_at) %>
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 %> <%= f.text_field :confirmed_at, readonly: true, placeholder: t(:no) %>
<% 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="actions"> <div class="flex">
<%= f.submit "Resend unlock instructions" %> <%= f.submit "Resend unlock instructions" %>
</div> </div>
<% end %> <% end %>

6
bin/ci Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require "active_support/continuous_integration"
CI = ActiveSupport::ContinuousIntegration
require_relative "../config/ci.rb"

2
bin/dev Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env ruby
exec "./bin/rails", "server", *ARGV

0
bin/fixinme.service.dist Normal file → Executable file
View File

View File

@@ -1,11 +1,10 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
require "fileutils" require "fileutils"
# path to your application root.
APP_ROOT = File.expand_path("..", __dir__) APP_ROOT = File.expand_path("..", __dir__)
def system!(*args) def system!(*args)
system(*args) || abort("\n== Command #{args} failed ==") system(*args, exception: true)
end end
FileUtils.chdir APP_ROOT do FileUtils.chdir APP_ROOT do
@@ -14,7 +13,6 @@ FileUtils.chdir APP_ROOT do
# Add necessary setup steps to this file. # Add necessary setup steps to this file.
puts "== Installing dependencies ==" puts "== Installing dependencies =="
system! "gem install bundler --conservative"
system("bundle check") || system!("bundle install") system("bundle check") || system!("bundle install")
# puts "\n== Copying sample files ==" # puts "\n== Copying sample files =="
@@ -24,10 +22,14 @@ FileUtils.chdir APP_ROOT do
puts "\n== Preparing database ==" puts "\n== Preparing database =="
system! "bin/rails db:prepare" system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
puts "\n== Removing old logs and tempfiles ==" puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear" system! "bin/rails log:clear tmp:clear"
puts "\n== Restarting application server ==" unless ARGV.include?("--skip-server")
system! "bin/rails restart" puts "\n== Starting development server =="
STDOUT.flush # flush the output before exec(2) so that it displays
exec "bin/dev"
end
end end

View File

@@ -18,14 +18,17 @@ require "rails/test_unit/railtie"
# you've limited to :test, :development, or :production. # you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups) Bundler.require(*Rails.groups)
require_relative '../lib/default_settings_strategy'
module FixinMe module FixinMe
class Application < Rails::Application class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version. # Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0 config.load_defaults 8.1
# Autoload lib/, required e.g. for core library extensions. # Please, add to the `ignore` list any other `lib` subdirectories that do
# https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#config-autoload-lib-ignore. # not contain `.rb` files, or that should not be reloaded or eager loaded.
config.autoload_lib(ignore: %w(assets tasks)) # Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here. # Configuration for the application, engines, and railties goes here.
# #
@@ -38,16 +41,19 @@ module FixinMe
config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden
config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity
# Set default migrations parameters.
config.active_record.migration_strategy = DefaultSettingsStrategy
# SETUP: Below settings need to be updated on a per-installation basis. # SETUP: Below settings need to be updated on a per-installation basis.
# #
# URL to use in sent e-mails. # Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {host: 'localhost', :protocol => 'https'} config.action_mailer.default_url_options = {host: 'localhost', protocol: 'https'}
# https://guides.rubyonrails.org/configuring.html#config-action-mailer-delivery-method # https://guides.rubyonrails.org/configuring.html#config-action-mailer-delivery-method
config.action_mailer.delivery_method = :sendmail config.action_mailer.delivery_method = :sendmail
# List of hosts this app is available at. # List of hosts this app is available at.
# https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization # https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization
config.hosts += ['localhost', 'example.com', IPAddr.new('1.2.3.4/32')] config.hosts |= ['localhost']
# Email address of admin account # Email address of admin account
config.admin = 'admin@localhost' config.admin = 'admin@localhost'

20
config/ci.rb Normal file
View File

@@ -0,0 +1,20 @@
# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Tests: Rails", "bin/rails test"
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
# Optional: Run system tests
# step "Tests: System", "bin/rails test:system"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end

View File

@@ -24,27 +24,45 @@
# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database # Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
# for a full overview on how database connection configuration can be specified. # for a full overview on how database connection configuration can be specified.
default: &default default: &default
adapter: mysql2 pool: <%= ENV.fetch('RAILS_MAX_THREADS', 3) %>
encoding: utf8mb4
collation: utf8mb4_0900_as_ci #mysql_default: &mysql_default
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> # <<: *default
username: fixinme # username: fixinme
password: Some-password1% # password: Some-password1%
socket: /run/mysqld/mysqld.sock # host: 127.0.0.1
# encoding: utf8mb4
# collation: utf8mb4_0900_as_ci
production: production:
<<: *default <<: *default
database: fixinme adapter: sqlite3
database: db/production.sqlite3
# Unless you're planning on developing the application, you can skip # Unless you're planning on developing the application, you can skip/remove
# configurations for development and test databases altogether. # configurations for development and test databases altogether.
#development: #development:
# <<: *default # <<: *mysql_default
# adapter: mysql2
# database: fixinme_dev # database: fixinme_dev
# Warning: The database defined as "test" will be erased and # Warning: The database(s) defined as "test" will be erased and
# re-generated from your development database when you run "rake". # re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production. # Do not set this db to the same as development or production.
#test: #test:
# <<: *default # <<: *mysql_default
# adapter: mysql2
# database: fixinme_test # database: fixinme_test
# Multiple test databases can be provided. When more than one test db is
# present, every test task (test, test:models, test:system, etc.) runs against
# all of them.
#test:
# mysql2:
# <<: *mysql_default
# adapter: mysql2
# database: fixinme_test
# sqlite3:
# <<: *default
# adapter: sqlite3
# database: db/test.sqlite3

View File

@@ -3,10 +3,8 @@ require "active_support/core_ext/integer/time"
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# In the development environment your application's code is reloaded any time # Make code changes take effect immediately without server restart.
# it changes. This slows down response time but is perfect for development config.enable_reloading = true
# since you don't have to restart the web server when you make code changes.
config.cache_classes = false
# Do not eager load code on boot. # Do not eager load code on boot.
config.eager_load = false config.eager_load = false
@@ -14,54 +12,52 @@ Rails.application.configure do
# Show full error reports. # Show full error reports.
config.consider_all_requests_local = true config.consider_all_requests_local = true
# Enable server timing # Enable server timing.
config.server_timing = true config.server_timing = true
# Enable/disable caching. By default caching is disabled. # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle caching. # Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist? if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
config.cache_store = :memory_store
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{2.days.to_i}"
}
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
config.cache_store = :null_store
end end
# Change to :null_store to avoid any caching.
config.cache_store = :memory_store
# Don't care if the mailer can't send. # Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false config.action_mailer.raise_delivery_errors = false
# Make template changes take effect immediately.
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
# Print deprecation notices to the Rails logger. # Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log config.active_support.deprecation = :log
# Raise exceptions for disallowed deprecations.
config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
config.active_support.disallowed_deprecation_warnings = []
# Raise an error on page load if there are pending migrations. # Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load config.active_record.migration_error = :page_load
# Highlight code that triggered database queries in logs. # Highlight code that triggered database queries in logs.
config.active_record.verbose_query_logs = true config.active_record.verbose_query_logs = true
# Suppress logger output for asset requests. # Append comments with runtime information tags to SQL queries in logs.
config.assets.quiet = true config.active_record.query_log_tags_enabled = true
# Highlight code that enqueued background job in logs.
config.active_job.verbose_enqueue_logs = true
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Raises error for missing translations. # Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true # config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names. # Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true config.action_view.annotate_rendered_view_with_filenames = true
# Uncomment if you wish to allow Action Cable access from any origin. # Raise error when a before_action's only/except options reference missing actions.
# config.action_cable.disable_request_forgery_protection = true config.action_controller.raise_on_missing_callback_actions = true
end end

View File

@@ -4,78 +4,83 @@ Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests. # Code is not reloaded between requests.
config.cache_classes = true config.enable_reloading = false
# Eager load code on boot. This eager loads most of Rails and # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
# your application in memory, allowing both threaded web servers
# and those relying on copy on write to perform better.
# Rake tasks automatically ignore this option for performance.
config.eager_load = true config.eager_load = true
# Full error reports are disabled and caching is turned on. # Full error reports are disabled.
config.consider_all_requests_local = false config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
config.action_controller.perform_caching = true config.action_controller.perform_caching = true
# Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] # Cache assets for far-future expiry since they are all digest stamped.
# or in config/master.key. This key is used to decrypt credentials (and other encrypted files). config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# config.require_master_key = true
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
# Compress CSS using a preprocessor.
# config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed.
config.assets.compile = false
# Enable serving of images, stylesheets, and JavaScripts from an asset server. # Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com" # config.asset_host = "http://assets.example.com"
# Specifies the header that your server uses for sending files. # Assume all access to the app is happening through a SSL-terminating reverse proxy.
# config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache config.assume_ssl = true
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
# config.force_ssl = true config.force_ssl = true
# Include generic and useful information about system operation, but avoid logging too much # Skip http-to-https redirect for the default health check endpoint.
# information to avoid inadvertent exposure of personally identifiable information (PII). # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
config.log_level = :info
# Prepend all log lines with the following tags. # Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ] config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Use a different cache store in production. # Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
# config.cache_store = :mem_cache_store # config.cache_store = :mem_cache_store
config.action_mailer.perform_caching = false # Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque
# Ignore bad email addresses and do not raise email delivery errors. # Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false # config.action_mailer.raise_delivery_errors = false
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found). # the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true config.i18n.fallbacks = true
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Use default logging formatter so that PID and timestamp are not suppressed.
config.log_formatter = ::Logger::Formatter.new
# Use a different logger for distributed setups.
# require "syslog/logger"
# config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name")
if ENV["RAILS_LOG_TO_STDOUT"].present?
logger = ActiveSupport::Logger.new(STDOUT)
logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger)
end
# Do not dump schema after migrations. # Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
# Enable DNS rebinding protection and other `Host` header attacks.
# config.hosts = [
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
# ]
#
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
# Protect e-mail addresses from being logged only in production.
config.filter_parameters += [:email]
end end

View File

@@ -1,5 +1,3 @@
require "active_support/core_ext/integer/time"
# The test environment is used exclusively to run your application's # The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that # test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped # your test database is "scratch space" for the test suite and is wiped
@@ -8,54 +6,49 @@ require "active_support/core_ext/integer/time"
Rails.application.configure do Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# Turn false under Spring and add config.action_view.cache_template_loading = true. # While tests run files are not watched, reloading is not necessary.
config.cache_classes = true config.enable_reloading = false
# Eager loading loads your whole application. When running a single test locally, # Eager loading loads your entire application. When running a single test locally,
# this probably isn't necessary. It's a good idea to do in a continuous integration # this is usually not necessary, and can slow down your test suite. However, it's
# system, or in some way before deploying your code. # recommended that you enable it in continuous integration systems to ensure eager
# loading is working properly before deploying your code.
config.eager_load = ENV["CI"].present? config.eager_load = ENV["CI"].present?
# Configure public file server for tests with Cache-Control for performance. # Configure public file server for tests with cache-control for performance.
config.public_file_server.enabled = true config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
}
# Hide full error reports. # Behave as in `production`.
config.consider_all_requests_local = false config.consider_all_requests_local = false
# Render exception templates instead of raising exceptions.
config.action_dispatch.show_exceptions = :all
# Disable caching.
config.action_controller.perform_caching = false
config.cache_store = :null_store config.cache_store = :null_store
# Disable request forgery protection in test environment. # Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false config.action_controller.allow_forgery_protection = false
config.action_mailer.perform_caching = false
# Tell Action Mailer not to deliver emails to the real world. # Tell Action Mailer not to deliver emails to the real world.
# The :test delivery method accumulates sent emails in the # The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array. # ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test config.action_mailer.delivery_method = :test
config.action_mailer.default_url_options = {host: '127.0.0.1', :protocol => 'http'}
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {host: Capybara.server_host,
protocol: 'http'}
# Print deprecation notices to the stderr. # Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr config.active_support.deprecation = :stderr
# Raise exceptions for disallowed deprecations.
config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
config.active_support.disallowed_deprecation_warnings = []
# Raises error for missing translations. # Raises error for missing translations.
config.i18n.raise_on_missing_translations = true config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names. # Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true # config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Allow Capybara application server IP
config.hosts |= [IPAddr.new(Capybara.server_host)]
config.log_level = :info config.log_level = :info
end end

View File

@@ -5,8 +5,3 @@ Rails.application.config.assets.version = "1.0"
# Add additional assets to the asset load path. # Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path # Rails.application.config.assets.paths << Emoji.images_path
# Precompile additional assets.
# application.js, application.css, and all non-JS/CSS in the app/assets
# folder are already added.
Rails.application.config.assets.precompile += %w( '*.svg' )

View File

@@ -16,9 +16,13 @@
# # policy.report_uri "/csp-violation-report-endpoint" # # policy.report_uri "/csp-violation-report-endpoint"
# end # end
# #
# # Generate session nonces for permitted importmap and inline scripts # # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src) # config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
# #
# # Report violations without enforcing the policy. # # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true # # config.content_security_policy_report_only = true

View File

@@ -1,4 +1,6 @@
require 'core_ext/big_decimal_scientific_notation' require 'core_ext/array_delete_bang'
require 'core_ext/float'
require 'core_ext/range'
ActiveSupport.on_load :action_dispatch_system_test_case do ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
@@ -9,9 +11,6 @@ ActiveSupport.on_load :action_view do
end end
ActiveSupport.on_load :active_record do ActiveSupport.on_load :active_record do
ActiveModel::Validations::NumericalityValidator
.prepend CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale
# Temporary patch for https://github.com/rails/rails/pull/54658 # Temporary patch for https://github.com/rails/rails/pull/54658
Arel::TreeManager::StatementMethods Arel::TreeManager::StatementMethods
.prepend CoreExt::Arel::TreeManager::StatementMethodsCteUpdateAndDelete .prepend CoreExt::Arel::TreeManager::StatementMethodsCteUpdateAndDelete

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.
@@ -179,7 +179,7 @@ Devise.setup do |config|
# ==> Configuration for :validatable # ==> Configuration for :validatable
# Range for password length. # Range for password length.
config.password_length = 5..128 config.password_length = 5..32
# Email regex used to validate email formats. It simply asserts that # Email regex used to validate email formats. It simply asserts that
# one (and only one) @ exists in the given string. This is mainly # one (and only one) @ exists in the given string. This is mainly

View File

@@ -1,8 +1,8 @@
# Be sure to restart your server when you modify this file. # Be sure to restart your server when you modify this file.
# Configure parameters to be filtered from the log file. Use this to limit dissemination of # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported # Use this to limit dissemination of sensitive information.
# notations and behaviors. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
] ]

View File

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

View File

@@ -19,7 +19,19 @@ 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_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." send_paranoid_instructions: >
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." If your email address is in our database, a message with instructions on how
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 %{authentication_keys} or password." invalid: "Invalid <b>%{authentication_keys}</b> or <b>password</b>."
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,8 +32,9 @@ 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_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." send_paranoid_instructions: >
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." If your email address is in our database, the password recovery link has been
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:
@@ -50,7 +51,6 @@ 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,22 +1,24 @@
en: en:
errors: time:
messages: formats:
precision_exceeded: must not exceed %{value} significant digits # Format contains non-breaking space: 160.chr(Encoding::UTF_8)
scale_exceeded: must not exceed %{value} decimal digits default: "%Y-%m-%d %H:%M %Z"
without_tz: "%Y-%m-%d %H:%M"
activerecord: activerecord:
attributes: attributes:
unit: quantity:
symbol: Symbol description: Description
name: Name name: Name
multiplier: Multiplier unit:
base: Base unit base: Base unit
description: Description
multiplier: Multiplier
symbol: Symbol
user: user:
email: e-mail confirmed_at: Confirmed
status: status created_at: Registered
password: password email: E-mail
created_at: registered status: Status
confirmed_at: confirmed
unconfirmed_email: Awaiting confirmation for
errors: errors:
models: models:
unit: unit:
@@ -53,9 +55,22 @@ 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
@@ -64,10 +79,12 @@ en:
source_code: Get code source_code: Get code
measurements: measurements:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
form:
select_quantity: select quantities...
taken_at_html: Measurement taken at&emsp;
index: index:
new_quantity: Selected new_measurement: Add measurement
new_children: Children
new_subtree: Subtree
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.
@@ -125,34 +142,27 @@ en:
disguise: View as disguise: View as
passwords: passwords:
edit: edit:
new_password: New password password_html: 'New password:%{password_length_hint_html}'
password_confirmation: Retype new password
update_password: Update password update_password: Update password
registrations: profiles:
new: new:
password_confirmation: Retype password password_html: 'Password:%{password_length_hint_html}'
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
unconfirmed_email_hint: (since %{timestamp}) password_html: >
new_password: New password New password:
password_confirmation: Retype new password <br><em>leave blank to keep unchanged</em>
blank_password_hint_html: leave blank to keep unchanged<br>%{subhint} %{password_length_hint_html}
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
or: or :no: 'no'
register: Register register: Register
sign_in: Sign in sign_in: Sign in
recover_password: Recover password recover_password: Recover password

View File

@@ -1,43 +1,41 @@
# Puma can serve each request in a thread from an internal thread pool. # This configuration file will be evaluated by Puma. The top-level methods that
# The `threads` method setting takes two numbers: a minimum and maximum. # are invoked here are part of Puma's configuration DSL. For more information
# Any libraries that use thread pools should be configured to match # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
# the maximum value specified for Puma. Default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record.
# #
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } # Puma starts a configurable number of processes (workers) and each process
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } # serves each request in a thread from an internal thread pool.
threads min_threads_count, max_threads_count
# Specifies the `worker_timeout` threshold that Puma will use to wait before
# terminating a worker in development environments.
# #
worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000. # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
# port ENV.fetch("PORT", 3000), '127.0.0.1'
port ENV.fetch("PORT") { 3000 }
# Specifies the `environment` that Puma will run in.
#
environment ENV.fetch("RAILS_ENV") { "development" }
# Specifies the `pidfile` that Puma will use.
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
# Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together
# the concurrency of the application would be max `threads` * `workers`.
# Workers do not work on JRuby or Windows (both of which do not support
# processes).
#
# workers ENV.fetch("WEB_CONCURRENCY") { 2 }
# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
# process behavior so workers use less memory.
#
# preload_app!
# Allow puma to be restarted by `bin/rails restart` command. # Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

View File

@@ -1,8 +1,8 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :measurements, path_names: {new: '/new(/:scope)'}, resources :measurements
constraints: {scope: /children|subtree/}, defaults: {scope: nil} do
get 'discard/:id', on: :new, action: :discard, as: :discard resources :readouts, only: [:new] do
collection {get 'new/:id/discard', action: :discard, as: :discard}
end end
resources :quantities, except: [:show], path_names: {new: '(/:id)/new'} do resources :quantities, except: [:show], path_names: {new: '(/:id)/new'} do
@@ -15,7 +15,10 @@ Rails.application.routes.draw do
namespace :default do namespace :default do
resources :units, only: [:index, :destroy] do resources :units, only: [:index, :destroy] do
member { post :import, :export } member {
post :import
post :export
}
#collection { post :import_all } #collection { post :import_all }
end end
end end
@@ -24,8 +27,9 @@ 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: :registrations} controllers: {registrations: 'user/profiles'}
end end
resources :users, only: [:index, :show, :update] do resources :users, only: [:index, :show, :update] do
@@ -34,9 +38,7 @@ 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

View File

@@ -1,11 +1,11 @@
class CreateUsers < ActiveRecord::Migration[7.0] class CreateUsers < ActiveRecord::Migration[8.1]
def change def change
create_table :users do |t| create_table :users do |t|
t.string :email, null: false, limit: 64 t.string :email, null: false, limit: 64
t.integer :status, null: false, default: 0 t.integer :status, null: false, default: 0
t.timestamps null: false t.timestamps
end end
add_index :users, :email, unique: true add_index :users, :email
end end
end end

View File

@@ -1,4 +1,4 @@
class AddDeviseToUsers < ActiveRecord::Migration[7.0] class AddDeviseToUsers < ActiveRecord::Migration[8.1]
def change def change
change_table :users do |t| change_table :users do |t|
## NOTE: commented fields left for reference/inclusion in future migrations ## NOTE: commented fields left for reference/inclusion in future migrations
@@ -34,8 +34,8 @@ class AddDeviseToUsers < ActiveRecord::Migration[7.0]
# t.integer :failed_attempts, default: 0, null: false # t.integer :failed_attempts, default: 0, null: false
end end
add_index :users, :reset_password_token, unique: true add_index :users, :reset_password_token
add_index :users, :confirmation_token, unique: true add_index :users, :confirmation_token
# add_index :users, :unlock_token, unique: true # add_index :users, :unlock_token, unique: true
end end
end end

View File

@@ -1,14 +1,15 @@
class CreateUnits < ActiveRecord::Migration[7.0] class CreateUnits < ActiveRecord::Migration[8.1]
def change def change
create_table :units do |t| create_table :units do |t|
t.references :user, foreign_key: true t.references :user, foreign_key: {on_delete: :cascade}
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.float :multiplier, null: false, limit: Float::MANT_DIG, default: 1.0
t.references :base, foreign_key: {to_table: :units} t.references :base, foreign_key: {to_table: :units, on_delete: :cascade}
t.timestamps null: false t.timestamps
end end
add_index :units, [:user_id, :symbol], unique: true add_index :units, [:user_id, :symbol]
add_index :units, [:id, :user_id]
end end
end end

View File

@@ -1,17 +1,18 @@
class CreateQuantities < ActiveRecord::Migration[7.2] class CreateQuantities < ActiveRecord::Migration[8.1]
def change def change
create_table :quantities do |t| create_table :quantities do |t|
t.references :user, foreign_key: true t.references :user, foreign_key: {on_delete: :cascade}
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} t.references :parent, foreign_key: {to_table: :quantities, on_delete: :cascade}
t.timestamps null: false t.timestamps
# Caches; can be computed from other attributes # Caches; can be computed from other attributes
t.integer :depth, null: false, default: 0 t.integer :depth, null: false, default: 0
t.string :pathname, null: false, limit: 511 t.string :pathname, null: false, limit: 511
end end
add_index :quantities, [:user_id, :parent_id, :name], unique: true add_index :quantities, [:user_id, :parent_id, :name]
add_index :quantities, [:id, :user_id]
end end
end end

View File

@@ -1,15 +1,69 @@
class CreateReadouts < ActiveRecord::Migration[7.2] class CreateReadouts < ActiveRecord::Migration[8.1]
def change def change
create_table :readouts do |t| create_table :notes do |t|
t.references :user, null: false, foreign_key: true t.text :text, null: false
t.references :quantity, null: false, foreign_key: true
t.references :unit, foreign_key: true t.timestamps
t.decimal :value, null: false, precision: 30, scale: 15 end
create_table :measurements do |t|
t.datetime :taken_at, null: false
#t.references :collector, foreign_key: true #t.references :collector, foreign_key: true
#t.references :device, foreign_key: true #t.references :device, foreign_key: true
t.references :note, foreign_key: {on_delete: :nullify}
t.timestamps null: false t.timestamps
end end
add_index :readouts, [:quantity_id, :created_at], unique: true add_index :measurements, :taken_at
# Defining Readouts as a super-/subclass polymorphic relations for different
# subclass data types (numeric, string, file) is not possible with proper
# referential integrity constraints. The required constraints are:
# * for every subclass record to have superclass record,
# * for every superclass record to have only one type of subclass record,
# * for every superclass record to have subclass record (unenforcable).
# * this can be partially remedied by making superlass an abstract class in
# Rails and disallow direct creation of records, but direct data
# manipulation can still break referential integrity.
# Defining separate {Numeric,Text,File}_Readouts tables would make the
# unique index constraint unenforcable.
create_table :readouts do |t|
t.references :user, null: false, foreign_key: {on_delete: :cascade}
t.references :measurement, foreign_key: {on_delete: :cascade}
t.references :quantity, null: false, foreign_key: {on_delete: :cascade}
t.integer :category, null: false, default: 0
t.float :value, null: false, limit: Float::MANT_DIG
t.references :unit, null: false, foreign_key: {on_delete: :cascade}
# TODO: consider additional columns to allow wider range of value types
# t.text :text
# t.datetime :time
# t.references :file
# Possibly mutually exclusive with :unit or check constraint for:
# :unit is not null <=> :value is not null
t.timestamps
end
add_index :readouts, [:measurement_id, :quantity_id, :category]
add_foreign_key :readouts, :quantities, column: [:quantity_id, :user_id],
primary_key: [:id, :user_id]
add_foreign_key :readouts, :units, column: [:unit_id, :user_id],
primary_key: [:id, :user_id]
# TODO: remove below tables after current setup verified
#create_table :numeric_values do |t|
# t.references :readout, null: false, foreign_key: {on_delete: :cascade}
# t.float :value, null: false, limit: Float::MANT_DIG
# t.references :unit, null: false, foreign_key: {on_delete: :cascade}
# # + generated, not stored column :value_type
# # + foreign key constraint to readouts: [:readout_id, :value_id, :value_type]
# # or 2 constraints: [:readout_id, :value_id], [:value_id, :value_type]
# # if readouts.value_id needed, otherwise just one constraint:
# # [:readout_id, :value_type]
#end
#create_table :string_values do |t|
# t.references :readout, null: false, foreign_key: {on_delete: :cascade}
# t.string :value, null: false, limit: 32
#end
end end
end end

View File

@@ -10,7 +10,22 @@
# #
# 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: 2025_01_21_230456) do ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
create_table "measurements", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.datetime "taken_at", null: false
t.bigint "note_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["note_id"], name: "index_measurements_on_note_id"
t.index ["taken_at"], name: "index_measurements_on_taken_at", unique: true
end
create_table "notes", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.text "text", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
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,6 +35,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) 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.index ["id", "user_id"], name: "index_quantities_on_id_and_user_id", unique: true
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"
@@ -27,13 +43,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
create_table "readouts", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t| create_table "readouts", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.bigint "measurement_id"
t.bigint "quantity_id", null: false t.bigint "quantity_id", null: false
t.bigint "unit_id" t.integer "category", default: 0, null: false
t.decimal "value", precision: 30, scale: 15, null: false t.float "value", limit: 53, null: false
t.bigint "unit_id", 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.index ["quantity_id", "created_at"], name: "index_readouts_on_quantity_id_and_created_at", unique: true t.index ["measurement_id", "quantity_id", "category"], name: "index_readouts_on_measurement_id_and_quantity_id_and_category", unique: true
t.index ["measurement_id"], name: "index_readouts_on_measurement_id"
t.index ["quantity_id", "user_id"], name: "fk_rails_9d92eaafc6"
t.index ["quantity_id"], name: "index_readouts_on_quantity_id" t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id", "user_id"], name: "fk_rails_348b0fb4c5"
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"
end end
@@ -42,11 +63,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
t.bigint "user_id" t.bigint "user_id"
t.string "symbol", limit: 15, null: false t.string "symbol", limit: 15, null: false
t.text "description" t.text "description"
t.decimal "multiplier", precision: 30, scale: 15, default: "1.0", null: false t.float "multiplier", limit: 53, default: 1.0, null: false
t.bigint "base_id" t.bigint "base_id"
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.index ["base_id"], name: "index_units_on_base_id" t.index ["base_id"], name: "index_units_on_base_id"
t.index ["id", "user_id"], name: "index_units_on_id_and_user_id", unique: true
t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true
t.index ["user_id"], name: "index_units_on_user_id" t.index ["user_id"], name: "index_units_on_user_id"
end end
@@ -69,11 +91,15 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) 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" add_foreign_key "measurements", "notes", on_delete: :nullify
add_foreign_key "quantities", "users" add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "readouts", "quantities" add_foreign_key "quantities", "users", on_delete: :cascade
add_foreign_key "readouts", "units" add_foreign_key "readouts", "measurements", on_delete: :cascade
add_foreign_key "readouts", "users" add_foreign_key "readouts", "quantities", column: ["quantity_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "units", "units", column: "base_id" add_foreign_key "readouts", "quantities", on_delete: :cascade
add_foreign_key "units", "users" add_foreign_key "readouts", "units", column: ["unit_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "units", on_delete: :cascade
add_foreign_key "readouts", "users", on_delete: :cascade
add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users", on_delete: :cascade
end end

View File

@@ -7,17 +7,21 @@
User.transaction do User.transaction do
break if User.find_by status: :admin break if User.find_by status: :admin
User.create! email: Rails.configuration.admin, password: 'admin', status: :admin do |user| email = Rails.configuration.admin
password_length = SecureRandom.rand(Rails.configuration.devise.password_length)
password = SecureRandom.alphanumeric(password_length)
User.create!(email: email, password: password, status: :admin) do |user|
user.skip_confirmation! user.skip_confirmation!
print "Creating #{user.status} account '#{user.email}' with password '#{user.password}'..." print "Creating #{user.status} account '#{user.email}'" \
" with password '#{user.password}'..."
end end
puts "done." puts "done."
rescue ActiveRecord::RecordInvalid => exception rescue ActiveRecord::RecordInvalid => exception
puts "failed. #{exception.message}" puts "failed.", exception.message
end end
# Formulas will be deleted as dependent on Quantities # Formulas will be deleted as dependent on Quantities
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all } #[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
require_relative 'seeds/units.rb' load "db/seeds/units.rb"

View File

@@ -1,10 +1,10 @@
Unit.transaction do Unit.transaction do
Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all Unit.defaults.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? %>
units['<%= unit.symbol %>'] = units['<%= unit.symbol %>'] =
Unit.create symbol: '<%= unit.symbol %>',<% unless unit.base.nil? %> base: units['<%= unit.base.symbol %>'], multiplier: '<%= unit.multiplier.to_scientific %>',<% end %> Unit.create symbol: '<%= unit.symbol %>',<% if unit.base_id? %> base: units['<%= unit.base.symbol %>'], multiplier: '<%= unit.multiplier.to_scientific %>',<% end %>
description: '<%= unit.description %>' description: '<%= unit.description %>'
<% end %> <% end %>
end end

View File

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

103
db/sqlite3_schema.rb Normal file
View File

@@ -0,0 +1,103 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_01_21_230456) do
create_table "measurements", force: :cascade do |t|
t.datetime "taken_at", null: false
t.integer "note_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["note_id"], name: "index_measurements_on_note_id"
t.index ["taken_at"], name: "index_measurements_on_taken_at", unique: true
end
create_table "notes", force: :cascade do |t|
t.text "text", limit: 65535, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "quantities", force: :cascade do |t|
t.integer "user_id"
t.string "name", limit: 31, null: false
t.text "description", limit: 65535
t.integer "parent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false
t.index ["id", "user_id"], name: "index_quantities_on_id_and_user_id", unique: true
t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id"
end
create_table "readouts", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "measurement_id"
t.integer "quantity_id", null: false
t.integer "category", default: 0, null: false
t.float "value", limit: 53, null: false
t.integer "unit_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["measurement_id", "quantity_id", "category"], name: "index_readouts_on_measurement_id_and_quantity_id_and_category", unique: true
t.index ["measurement_id"], name: "index_readouts_on_measurement_id"
t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id"], name: "index_readouts_on_unit_id"
t.index ["user_id"], name: "index_readouts_on_user_id"
end
create_table "units", force: :cascade do |t|
t.integer "user_id"
t.string "symbol", limit: 15, null: false
t.text "description", limit: 65535
t.float "multiplier", limit: 53, default: 1.0, null: false
t.integer "base_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_id"], name: "index_units_on_base_id"
t.index ["id", "user_id"], name: "index_units_on_id_and_user_id", unique: true
t.index ["user_id", "symbol"], name: "index_units_on_user_id_and_symbol", unique: true
t.index ["user_id"], name: "index_units_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "email", limit: 64, null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
t.string "confirmation_token"
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"
t.string "unconfirmed_email", limit: 64
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
add_foreign_key "measurements", "notes", on_delete: :nullify
add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "users", on_delete: :cascade
add_foreign_key "readouts", "measurements", on_delete: :cascade
add_foreign_key "readouts", "quantities", column: ["quantity_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "quantities", on_delete: :cascade
add_foreign_key "readouts", "units", column: ["unit_id", "user_id"], primary_key: ["id", "user_id"]
add_foreign_key "readouts", "units", on_delete: :cascade
add_foreign_key "readouts", "users", on_delete: :cascade
add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users", on_delete: :cascade
end

View File

@@ -1,19 +0,0 @@
module CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale
def validate_each(record, attr_name, value, ...)
super(record, attr_name, value, ...)
if options[:precision] || options[:scale]
attr_type = record.class.type_for_attribute(attr_name)
# For conversion of 'value' to BigDecimal 'ndigits' is not supplied intentionally,
# to avoid silent rounding. It is only required for conversion from Float and
# Rational, which should not happen.
value = BigDecimal(value) unless value.is_a? BigDecimal
if options[:precision] && (value.precision > attr_type.precision)
record.errors.add(attr_name, :precision_exceeded, **filtered_options(attr_type.precision))
end
if options[:scale] && (value.scale > attr_type.scale)
record.errors.add(attr_name, :scale_exceeded, **filtered_options(attr_type.scale))
end
end
end
end

View File

@@ -0,0 +1,10 @@
module CoreExt
module ArrayDeleteBang
def delete!(obj)
self.delete(obj)
self
end
end
end
Array.prepend CoreExt::ArrayDeleteBang

View File

@@ -1,36 +0,0 @@
module CoreExt
module BigDecimalScientificNotation
def to_scientific
return 'NaN' unless finite?
sign, coefficient, base, exponent = split
(sign == -1 ? '-' : '') +
(coefficient.length > 1 ? coefficient.insert(1, '.') : coefficient) +
(exponent != 1 ? "e#{exponent-1}" : '')
end
# Converts value to HTML formatted scientific notation
def to_html
sign, coefficient, base, exponent = split
return 'NaN' unless sign
result = (sign == -1 ? '-' : '')
unless coefficient == '1' && sign == 1
if coefficient.length > 1
result += coefficient.insert(1, '.')
elsif
result += coefficient
end
if exponent != 1
result += "&times;"
end
end
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
end
result.html_safe
end
end
end
BigDecimal.prepend CoreExt::BigDecimalScientificNotation

54
lib/core_ext/float.rb Normal file
View File

@@ -0,0 +1,54 @@
# If a decimal string with at most 15 significant digits is converted to the
# IEEE 754 double-precision format, giving a normal number, and then converted
# back to a decimal string with the same number of digits, the final result
# should match the original string.
# If an IEEE 754 double-precision number is converted to a decimal string with
# at least 17 significant digits, and then converted back to double-precision
# representation, the final result must match the original number:
# ("%.16e" % self).to_f == self
class Float
def to_scientific
sign, significand, exponent = split
sign + significand + (exponent != 0 ? "e#{exponent}" : '')
end
# Converts value to HTML formatted scientific notation.
def to_html(show_unity: true)
result, significand, exponent = split
result += significand if significand != '1' || (exponent == 0 && show_unity)
if exponent != 0
result += "&times;" if significand != '1'
result += "10<sup>% d</sup>" % exponent
end
result.html_safe
end
# Assume #finite? is true.
def limit(precision = DIG)
return 0.0 if precision.zero?
sign, significand, exponent = split(DIG_MAX)
"#{sign}#{significand[..precision]}e#{exponent}".to_f
end
private
SPLIT_FLOAT = /(-?)(.*?)\.?0*e(.*)/
# Format `%e` displays starting from significant digit (not 0).
def split(digits = DIG)
return ['', to_s[..2], 0] unless finite?
return ['', '0', 0] if digits.zero?
("%.#{digits - 1}e" % self).match(SPLIT_FLOAT).captures
.then { |sign, significand, exponent| [sign, significand, exponent.to_i] }
end
# The maximum number of significant decimal digits in a double-precision
# floating point number.
DIG_MAX = 17
# Smallest and largest double-precision floating point numbers with DIG
# precision.
# TODO: change MIN_15 to MIN.ceil(MIN_10_EXP - DIG) after #ceil fix in Ruby
# v4.0.5: https://bugs.ruby-lang.org/issues/22079
MIN_15 = MIN.ceil(-(MIN_10_EXP - 1))
MAX_15 = MAX.floor(-(MAX_10_EXP - DIG + 1))
end

31
lib/core_ext/range.rb Normal file
View File

@@ -0,0 +1,31 @@
class Range
# TODO: cleanup comments after commit
# * < nil, true < false
#if a.end == b.end
# a.exclude_end? ^ b.exclude_end? ? (a.exclude_end? ? -1 : 1) : 0
#else
# a.end <=> b.end || (a.end.nil? ? 1 : -1)
#end
#a.end == b.end ? (b.exclude_end? ? b : a) : [a, b].to_h.except(nil).min
#*(l[0] == r[0] ? (r[1] ? r : l) : [l, r].reject{ |e| e[0].nil? }.min)
def &(other)
case other
when Range
return nil unless self.overlap?(other)
both = [self, other]
return Range.new(
both.map(&:begin).compact.max,
*if self.end == other.end
other.exclude_end? ? [other] : [self]
else
both.select(&:end)
end.map { |r| [r.end, r.exclude_end?] }.min
)
when Array
return other.map { |o| self & o }.compact
else
return self.member?(other) ? other : nil
end
end
end

View File

@@ -0,0 +1,55 @@
class DefaultSettingsStrategy < ActiveRecord::Migration::DefaultStrategy
COLUMN_DEFAULTS = {
# TODO: all types `null: false` ?
# TODO: references `foreign_key: {on_delete: :cascade}` ?
# `timestamps` - Rails defaults to `null: false, precision: true`.
# `limit:` for `text` - `text` can be theoretically up to 1GB or
# longer, so roughly unlimited for practical purposes. But:
# * the actual usable length depends on additional factors, like compile
# time limits (`SQLITE_MAX_LENGTH` in SQLite), runtime settings
# (`max_allowed_packet` in MySQL) and probably other,
# * Rails does not report limit for `text` column types, unless it is
# explicitly set.
# The decision is to always set safe limit and enforce it by validations, to
# avoid surprises (e.g. text truncation) when saving to dabatase.
text: {limit: 2**16 - 1}
}
COLUMN_DEFAULTS.default = {}
COLUMN_DEFAULTS.freeze
module ColumnSettingsStrategy
def column(name, type, **options)
super(name, type, **COLUMN_DEFAULTS[type].merge(options))
end
end
ActiveRecord::ConnectionAdapters::TableDefinition.prepend ColumnSettingsStrategy
# `force: :cascade` - does nothing on MySQL, so `force:` is useless.
# `if_not_exists: true`/`if_exists: true` - without these options it's impossible
# to change migration status once migration fails partially. If `up` migration
# creates some, but not all objects, its status is not updated from `down` to
# `up`. Then it's impossible to migrate: a) up - due to `already exists`
# errors and b) down - due to migration status.
MIGRATION_DEFAULTS = {
add_check_constraint: {if_not_exists: true},
add_column: {if_not_exists: true},
add_foreign_key: {if_not_exists: true},
add_index: {if_not_exists: true, unique: true},
add_timestamps: {if_not_exists: true},
create_table: {if_not_exists: true},
drop_table: {if_exists: true},
remove_check_constraint: {if_exists: true},
remove_column: {if_exists: true},
remove_foreign_key: {if_exists: true},
remove_index: {if_exists: true},
remove_timestamps: {if_exists: true},
}
MIGRATION_DEFAULTS.default = {}
MIGRATION_DEFAULTS.freeze
def method_missing(method, *args, **kwargs, &)
conflicts = kwargs.has_key?(:force) ? [:if_not_exists, :if_exists] : []
defaults = MIGRATION_DEFAULTS[method.to_sym].except(*conflicts)
super(method, *args, **defaults.merge(kwargs), &)
end
end

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

3
public/icon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="red"/>
</svg>

After

Width:  |  Height:  |  Size: 122 B

View File

@@ -1,7 +1,7 @@
require "test_helper" require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
extend ActionView::Helpers::TranslationHelper include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::UrlHelper include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning # NOTE: geckodriver installed with Firefox, ignore incompatibility warning
@@ -17,17 +17,54 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
options.add_preference('browser.download.dir', "#{Rails.root}/tmp/") options.add_preference('browser.download.dir', "#{Rails.root}/tmp/")
end end
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).capitalize, with: user.email fill_in User.human_attribute_name(:email), with: user.email
fill_in User.human_attribute_name(:password).capitalize, with: password fill_in User.human_attribute_name(:password), with: password
click_on t(:sign_in) click_on t(:sign_in)
yield if block_given?
user user
end end
def inject_button_to(after, *button_options) def inject_button_to(inside, *button_options)
button = button_to *button_options button = button_to *button_options
evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after) inside.evaluate_script("this.insertAdjacentHTML('beforeend', arguments[0]);",
button.html_safe)
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
DB_CONFIGS = ActiveRecord::Base.configurations.configs_for(env_name: "test")
TEST_CONFIGS = Hash.new(DB_CONFIGS.first.name.to_sym)
class << self
# NOTE: alternative to current solution is to create shards:
# ActiveRecord::Base.connects_to(
# shards: {mysql2: {writing: :mysql2}, sqlite3: {writing: :sqlite3}}
# )
# and use them in one of the following ways:
# * set config.active_record.shard_selector/shard_resolver
# * run tests within: ActiveRecord::Base.connected_to(shard: :sqlite3) { test_ }
# Remove this note once current solution is confirmed to work.
#
# Test block should not be modified here, as it would change its binding from
# instance level to class level.
if DB_CONFIGS.many?
def test(name, ...)
DB_CONFIGS.each do |config|
TEST_CONFIGS[super("#{config.name} #{name}", ...)] = config.name.to_sym
end
end
end
end
setup do
ActiveRecord::Base.establish_connection(TEST_CONFIGS[name.to_sym])
end end
#def assert_stale(element) #def assert_stale(element)

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