Compare commits

..

2 Commits

Author SHA1 Message Date
3fe43d1fc0 Fix quantity ordered scope for SQLite: use pathname column instead of recursive CTE
SQLite's Arel visitor wraps CTE branches in extra parentheses, making
the UNION ALL inside recursive CTEs invalid. Also SQLite lacks LPAD()
and CAST(... AS BINARY). Fix by using the existing pathname column for
ordering on SQLite, which already encodes the hierarchical path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:41:03 +00:00
9b18784caf Implement measurements create/destroy and index listing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:24:27 +00:00
50 changed files with 472 additions and 1089 deletions

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 167 B

View File

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

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 293 B

View File

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

Before

Width:  |  Height:  |  Size: 135 B

View File

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

Before

Width:  |  Height:  |  Size: 135 B

View File

@@ -18,12 +18,10 @@
/* Strive for simplicity: /* Strive for simplicity:
* * style elements/tags only - if possible, * * style elements/tags only - if possible,
* * replace element/tag name with class name - if element has to be styled * * 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), * differently depending on context (e.g. form)
* * 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, * NOTE: Style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */ * to make transition easier once @scope is widely available */
:root { :root {
--color-focus-gray: #f3f3f3; --color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd; --color-border-gray: #dddddd;
@@ -36,7 +34,6 @@
--color-blue: #009ade; --color-blue: #009ade;
--color-dark-red: #b21237; --color-dark-red: #b21237;
--color-red: #ff1f5b; --color-red: #ff1f5b;
--color-purple: #8b2be2;
--depth: 0; --depth: 0;
@@ -56,36 +53,17 @@
:focus-visible { :focus-visible {
outline: none; outline: none;
} }
/* NOTE: move to higher priority layer instead of using !important?; add CSS
* @layer requirements in README */
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
/* NOTE: cannot set cursor when `pointer-events: none`; can be fixed by setting
* `cursor` on wrapping element.
cursor: not-allowed; */
fill: var(--color-border-gray) !important;
pointer-events: none !important;
}
/* Styles set `display` without distinguishing between [hidden] elements, making
* them visible. */
[hidden] {
display: none !important;
}
/* Color coding of input controls' background: /* Color coding of input controls' background:
* blue - target for interaction with pointer, * blue - target for interaction with pointer
* gray - target for interaction with keyboard, * gray - target for interaction with keyboard
* red - destructive, non-undoable action. * red - destructive, non-undoable action
*/ */
/* TODO: merge selectors using :is() */
a,
button, button,
details, details,
input, input,
select, select,
summary,
textarea { textarea {
background-color: inherit; background-color: inherit;
font: inherit; font: inherit;
@@ -95,24 +73,56 @@ input,
select { select {
text-align: inherit; text-align: inherit;
} }
a,
button,
input[type=submit] {
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
/* [hidden] submit controls cannot have `display` set as it makes them visible */
.button,
button:not([hidden]),
input[type=submit]:not([hidden]),
.tab {
align-items: center;
color: var(--color-gray);
display: flex;
fill: var(--color-gray);
font-weight: bold;
}
.button,
button,
input[type=submit] {
font-size: 0.8rem;
padding: 0.6em 0.5em;
width: fit-content;
}
input:not([type=submit]):not([type=checkbox]),
select,
summary,
textarea {
padding: 0.2em 0.4em;
}
.button,
button,
input, input,
select, select,
summary, summary,
textarea { textarea {
border: 1px solid var(--color-gray); border: solid 1px var(--color-gray);
border-radius: 0.25em; border-radius: 0.25em;
padding: 0.2em 0.4em;
} }
svg { [name=cancel],
height: 1.4em; .auxiliary {
margin: 0 0.2em 0 0; border-color: var(--color-border-gray);
width: 1.4em; color: var(--color-nav-gray);
} fill: var(--color-nav-gray);
svg:last-child {
margin-right: 0;
} }
input[type=checkbox],
svg,
textarea { textarea {
margin: 0; margin: 0
} }
input[type=checkbox] { input[type=checkbox] {
accent-color: var(--color-blue); accent-color: var(--color-blue);
@@ -120,20 +130,17 @@ input[type=checkbox] {
-webkit-appearance: none; -webkit-appearance: none;
display: flex; display: flex;
height: 1.1em; height: 1.1em;
margin: 0;
padding: 0;
width: 1.1em; width: 1.1em;
} }
input[type=checkbox]:checked { input[type=checkbox]:checked {
appearance: checkbox; appearance: checkbox;
-webkit-appearance: checkbox; -webkit-appearance: checkbox;
} }
/* Hide spin buttons of <input type=number>. */ /* Hide spin buttons in input number fields */
/* TODO: add spin buttons inside <input type=number>: before (-) and after (+) input. */ /* TODO: add spin buttons inside input[number]: before (-) and after (+) input */
input[type=number] { input[type=number] {
appearance: textfield; appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
text-align: end;
} }
input::-webkit-inner-spin-button { input::-webkit-inner-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
@@ -142,112 +149,37 @@ input::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
margin: 0; margin: 0;
} }
/* Text color of table form controls: .button > svg,
* - black for row/table forms, .tab > svg,
* - inherited for internal (column specific) buttons/forms. */ button > svg {
table input, height: 1.4em;
table select, width: 1.4em;
table summary,
table textarea {
border-color: var(--color-border-gray);
} }
table input, .button > svg:not(:last-child),
table select, .tab > svg:not(:last-child),
table textarea { button > svg:not(:last-child) {
padding-block: 0.375em; margin-right: 0.2em;
} }
table form input, /* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
table form select, * page-wide, top-level) and remove from table.items - as the style should be
table form summary, * same everywhere */
table form textarea { .button:focus-visible,
color: inherit; button:focus-visible,
} input[type=submit]:focus-visible {
table svg:not(:only-child) { background-color: var(--color-focus-gray);
height: 1.25em;
width: 1.25em;
} }
input:focus-visible, input:focus-visible,
select:focus-visible, select:focus-visible,
select:focus-within, select:focus-within,
/* TODO: how to achieve `summary:focus-within` for `::details-content`? */ /* TODO: how to achieve summary:focus-within for ::details-content? */
summary:focus-visible, summary:focus-visible,
textarea:focus-visible { textarea:focus-visible {
accent-color: var(--color-dark-blue); accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray); background-color: var(--color-focus-gray);
color: black;
} }
input:hover, .button:hover,
select:hover, button:hover,
summary:hover, input[type=submit]:hover {
textarea:hover {
border-color: var(--color-blue);
outline: 1px solid var(--color-blue);
}
select:hover,
summary:hover {
color: black;
cursor: pointer;
}
/* TODO: style <details>/<summary> focus to match <select> as much as possible.
summary:focus-visible::before,
summary:hover::before {
background-color: black;
}
*/
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline-color: var(--color-red);
}
/* `.button`: button-styled <a>, <button>, <input type=submit>.
* `.link`: any other <a>.
* `.tab`: tab-styled <a>.
*/
.button,
.link,
.tab {
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.button,
.tab {
align-items: center;
color: var(--color-gray);
display: flex;
fill: var(--color-gray);
font-weight: bold;
}
.button {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
font-size: 0.8rem;
padding: 0.6em 0.5em;
width: fit-content;
}
.link {
color: inherit;
text-decoration: underline 1px var(--color-border-gray);
text-underline-offset: 0.25em;
}
button.link {
border: none;
padding: 0;
}
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
.button:focus-visible,
.tab:focus-visible,
.tab:hover {
background-color: var(--color-focus-gray);
}
.button: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;
@@ -257,31 +189,32 @@ button.link {
background-color: var(--color-red); background-color: var(--color-red);
border-color: var(--color-red); border-color: var(--color-red);
} }
tr:has(select[data-changed]) button[name="button"], input:hover,
.set-default-unit:not([disabled]) { select:hover,
background-color: var(--color-purple); summary:hover,
border-color: var(--color-purple); textarea:hover {
color: white; border-color: var(--color-blue);
fill: white; outline: solid 1px var(--color-blue);
} }
.link:focus-visible { select:hover,
text-decoration-color: var(--color-gray); summary:hover {
cursor: pointer;
} }
.link:hover { input:invalid,
color: var(--color-blue); select:invalid,
text-decoration-color: var(--color-blue); textarea:invalid {
border-color: var(--color-red);
outline: solid 1px var(--color-red);
} }
table .button { input[type=text]:read-only,
border-color: var(--color-border-gray); textarea:read-only {
color: var(--color-table-gray); border: none;
font-weight: normal; padding-inline: 0;
height: 100%;
padding: 0.4em;
} }
/* NOTE: collapse gaps around empty rows (`topside`) once possible with /* NOTE: collapse gaps around empty rows (`topside`) once possible
* `grid-collapse` property and remove alternative `grid-template-areas`. * with grid-collapse property and remove alternative grid-template
* https://github.com/w3c/csswg-drafts/issues/5813 */ * https://github.com/w3c/csswg-drafts/issues/5813 */
body { body {
display: grid; display: grid;
@@ -289,16 +222,16 @@ body {
grid-template-areas: grid-template-areas:
"header header header" "header header header"
"nav nav nav" "nav nav nav"
"leftside topside rightside"
"leftside main rightside"; "leftside main rightside";
grid-template-columns: 1fr minmax(max-content, 2fr) 1fr; grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
font-family: system-ui; font-family: system-ui;
margin: 0.4em; margin: 0.4em;
} }
body:has(> .topside-area) { body:not(:has(.topside-area)) {
grid-template-areas: grid-template-areas:
"header header header" "header header header"
"nav nav nav" "nav nav nav"
"leftside topside rightside"
"leftside main rightside"; "leftside main rightside";
} }
@@ -314,14 +247,18 @@ header {
margin-inline-start: 4%; margin-inline-start: 4%;
} }
.navigation > .tab { .navigation > .tab {
border-bottom: 2px solid var(--color-nav-gray); border-bottom: solid 2px var(--color-nav-gray);
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
justify-content: center; justify-content: center;
padding-block: 0.4em; padding-block: 0.4em;
} }
.navigation > .tab:hover,
.navigation > .tab:focus-visible {
background-color: var(--color-focus-gray);
}
.navigation > .tab.active { .navigation > .tab.active {
border-bottom: 4px solid var(--color-blue); border-bottom: solid 4px var(--color-blue);
color: var(--color-blue); color: var(--color-blue);
fill: var(--color-blue); fill: var(--color-blue);
} }
@@ -353,7 +290,7 @@ header {
#flashes { #flashes {
display: grid; display: grid;
row-gap: 0.4em; gap: 0.2em;
grid-template-columns: 1fr auto auto auto 1fr; grid-template-columns: 1fr auto auto auto 1fr;
left: 0; left: 0;
pointer-events: none; pointer-events: none;
@@ -369,17 +306,13 @@ header {
display: grid; display: grid;
grid-column: 2/5; grid-column: 2/5;
grid-template-columns: subgrid; grid-template-columns: subgrid;
line-height: 2.2em;
pointer-events: auto; pointer-events: auto;
} }
.flash:before {
filter: invert(100%);
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
}
.flash.alert:before { .flash.alert:before {
content: url('pictograms/alert-outline.svg'); content: url('pictograms/alert-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
} }
.flash.alert { .flash.alert {
border-color: var(--color-red); border-color: var(--color-red);
@@ -387,24 +320,35 @@ header {
} }
.flash.notice:before { .flash.notice:before {
content: url('pictograms/check-circle-outline.svg'); content: url('pictograms/check-circle-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
} }
.flash.notice { .flash.notice {
border-color: var(--color-blue); border-color: var(--color-blue);
background-color: var(--color-blue); background-color: var(--color-blue);
} }
.flash svg { .flash > div {
cursor: pointer; grid-column: 2;
fill: white;
height: 2.2em;
opacity: 0.6;
padding: 0.4em 0.5em;
width: 2.4em;
} }
.flash svg:hover { /* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
cursor: pointer;
font-size: 1.4em;
font-weight: bold;
grid-column: 3;
opacity: 0.6;
padding: 0.2em 0.4em;
}
.flash > button:hover {
opacity: 1; opacity: 1;
} }
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
.labeled-form { .labeled-form {
align-items: center; align-items: center;
display: grid; display: grid;
@@ -421,7 +365,7 @@ header {
.labeled-form label.required { .labeled-form label.required {
font-weight: bold; font-weight: bold;
} }
/* Don't style `label.error + input` if case already covered by `input:invalid`. */ /* Don't style `label.error + input` if case already covered by input:invalid */
.labeled-form label.error { .labeled-form label.error {
color: var(--color-red); color: var(--color-red);
} }
@@ -441,117 +385,200 @@ header {
.labeled-form .auxiliary { .labeled-form .auxiliary {
grid-column: 3; grid-column: 3;
/* If more buttons are needed, `grid-row` can be replaced with /* If more buttons are needed, `grid-row` can be replaced with
* `reading-flow: grid-columns` to ensure proper [tabindex] order. */ * `reading-flow: grid-columns` to ensure proper tabindex order */
grid-row: 1; grid-row: 1;
height: 100%; height: 100%;
padding-block: 0; padding-block: 0;
} }
.tabular-form table {
border: none;
border-spacing: 0.4em 0;
margin-inline: -0.4em;
}
.tabular-form table td {
border: none;
vertical-align: middle;
}
.tabular-form table td {
padding-inline: 0;
}
.tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0;
}
/* TODO: remove .items class (?) and make 'form table' work properly */
.items-table { table.items {
border-spacing: 0; border-spacing: 0;
border: 1px solid var(--color-border-gray); border: solid 1px var(--color-border-gray);
border-radius: 0.25em; border-radius: 0.25em;
font-size: 0.85rem; font-size: 0.85rem;
text-align: left; text-align: left;
} }
.items-table thead { table:not(:has(tr)) {
display: none;
}
table.items thead {
font-size: 0.8rem; font-size: 0.8rem;
} }
.items-table thead, table.items thead,
.items-table tbody tr:hover { table.items tbody tr:hover {
background-color: var(--color-focus-gray); background-color: var(--color-focus-gray);
} }
.items-table th { table.items th {
padding: 0.75em 0 0.75em 1em; padding-block: 0.75em;
text-align: center; text-align: center;
} }
.items-table th:last-child { table.items th,
padding-inline-end: 0.4em; table.items td {
padding-inline: 1em 0;
} }
.items-table td { /* For <a> to fill <td> completely, we use an ::after pseudoelement. */
border-top: 1px solid var(--color-border-gray); table.items td.link {
height: 2.4em; padding: 0;
padding: 0.1em 0 0.1em calc(1em + var(--depth) * 0.8em);
}
.items-table td:last-child {
padding-inline-end: 0.1em;
}
.items-table :is(form, input, select, textarea):only-child {
margin-inline-start: calc(-0.4em - 0.9px);
}
/* For <a> to fill table cell completely, we use an `::after` pseudoelement. */
/* TODO: expand to whole row? will require adjusting z-index on inputs/buttons */
.items-table td:has(> .link) {
position: relative; position: relative;
} }
.items-table .link::after { table.items td.link a {
color: inherit;
font: inherit;
}
table.items td.link a::after {
content: ''; content: '';
inset: -1px 0 0 0; inset: 0;
position: absolute; position: absolute;
} }
.items-table .flex { table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em);
}
table.items td:has(input, select, textarea) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items td:first-child:has(input, select, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
}
table.items th:last-child {
padding-inline-end: 0.4em;
}
table.items td:last-child {
padding-inline-end: 0.1em;
}
table.items td {
border-top: solid 1px var(--color-border-gray);
height: 2.4em;
padding-block: 0.1em;
}
table.items .actions {
display: flex;
gap: 0.4em; gap: 0.4em;
justify-content: end; justify-content: end;
} }
.items-table .dropzone { table.items .actions.centered {
justify-content: center;
}
table.items tr.dropzone {
position: relative; position: relative;
} }
.items-table .dropzone::after { table.items tr.dropzone::after {
content: ''; content: '';
inset: 1px 0 0 0; inset: 1px 0 0 0;
position: absolute; position: absolute;
outline: 2px dashed var(--color-blue); outline: dashed 2px var(--color-blue);
outline-offset: -1px; outline-offset: -1px;
z-index: var(--z-index-table-row-outline); z-index: var(--z-index-table-row-outline);
} }
.items-table .handle { table.items td.handle {
cursor: grab; cursor: move;
} }
.items-table .form td { table.items tr.form td {
vertical-align: middle; vertical-align: top;
} }
.items-table td:not(:first-child),
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update table styling: simplify selectors, deduplicate, remove non-font rem. */
table.items td.link a:hover,
table.items td.link a:focus-visible,
table.items td.link a:hover:focus-visible {
text-decoration: underline;
text-decoration-thickness: 0.05rem;
text-underline-offset: 0.2rem;
}
table.items td.link a:hover {
color: var(--color-blue);
}
table.items td.link a:focus-visible {
text-decoration-color: var(--color-gray);
}
table.items td.link a:hover:focus-visible {
color: var(--color-dark-blue);
}
table.items td:not(:first-child),
.grayed { .grayed {
color: var(--color-table-gray); color: var(--color-table-gray);
fill: var(--color-gray); fill: var(--color-table-gray);
} }
.items-table td:has(> svg:only-child) { table.items svg {
height: 1rem;
vertical-align: middle;
width: 1rem;
}
table.items svg:last-child {
height: 1.2rem;
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;
height: 100%;
padding: 0.4em;
}
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 table.items {
border: none;
}
form table.items td {
border: none;
text-align: left;
vertical-align: middle;
}
form table.items td:first-child {
color: inherit;
}
.center { .centered {
margin: 0 auto; margin: 0 auto;
} }
.extendedright {
margin-right: auto;
}
.hexpand { .hexpand {
width: 100%; width: 100%;
} }
.flex { .hflex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
} }
.flex.reverse { .hflex.reverse {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.flex.vertical { .hflex.centered {
flex-direction: column; justify-content: center;
} }
.hint { .hint {
color: var(--color-table-gray); color: var(--color-table-gray);
@@ -559,18 +586,21 @@ header {
font-size: 0.9rem; font-size: 0.9rem;
text-align: center; text-align: center;
} }
.hmin50 { .vflex {
min-width: 50%; display: flex;
gap: 0.8em;
flex-direction: column;
} }
.italic { [disabled] {
color: var(--color-gray); /* label:has(input[disabled]) {
font-style: italic; * TODO: disabled checkbox blue square focus removal; disabled label styling;
} * focused label styling (currently only checkbox has focus)
.ralign { * */
text-align: right; border-color: var(--color-border-gray) !important;
} color: var(--color-border-gray) !important;
.rextend { cursor: not-allowed;
margin-right: auto; fill: var(--color-border-gray) !important;
pointer-events: none;
} }
@@ -582,12 +612,12 @@ summary {
align-items: center; align-items: center;
color: var(--color-gray); color: var(--color-gray);
display: flex; display: flex;
gap: 0.4em; gap: 0.2em;
height: 100%; height: 100%;
white-space: nowrap; white-space: nowrap;
} }
summary::before { summary::before {
background-color: currentColor; background-color: #000;
content: ""; content: "";
height: 1em; height: 1em;
mask-image: url('pictograms/chevron-down.svg'); mask-image: url('pictograms/chevron-down.svg');
@@ -599,7 +629,7 @@ summary:has(.button) {
padding-inline-end: 0; padding-inline-end: 0;
} }
summary .button { summary .button {
border: 1px solid var(--color-border-gray); border: solid 1px var(--color-border-gray);
border-radius: inherit; border-radius: inherit;
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
@@ -610,15 +640,15 @@ summary span {
width: 100%; width: 100%;
} }
details[open] summary::before { details[open] summary::before {
transform: scaleY(-1); transform: rotate(180deg);
} }
summary::marker { summary::marker {
padding-left: 0.25em; padding-left: 0.25em;
} }
/* NOTE: use `details[open]::details-content` once widely available. */ /* NOTE: use details[open]::details-content once widely available */
details[open] ul { details[open] ul {
background-color: white; background: white;
border: 1px solid var(--color-border-gray); border: solid 1px var(--color-border-gray);
border-radius: 0.25em; border-radius: 0.25em;
box-shadow: 1px 1px 3px var(--color-border-gray); box-shadow: 1px 1px 3px var(--color-border-gray);
margin: -1px 0 0 0; margin: -1px 0 0 0;
@@ -640,54 +670,3 @@ li input[type=checkbox] {
li::marker { li::marker {
content: ''; content: '';
} }
/*
* TODO:
* * disable <label> containing disabled checkbox: `label:has(input[disabled])`,
* * disabled label styling,
* * focused label styling (currently only checkbox has focus),
* * disabled checkbox blue square focus removal.
* */
#measurement_form {
min-width: 66%;
width: max-content;
}
.measurements-section {
overflow-x: auto;
}
body[data-measurements-view=wide] .measurements-compact,
body[data-measurements-view=compact] .measurements-wide {
display: none;
}
body[data-measurements-view=compact] .view-toggle[data-view=compact],
body[data-measurements-view=wide] .view-toggle[data-view=wide] {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
fill: white;
}
#measurements tr.grouped td {
border-top: none;
}
#measurements tr.grouped .taken-at,
#measurements tr.grouped .created-at {
visibility: hidden;
}
.measurements-wide td {
vertical-align: middle;
white-space: nowrap;
}
.wide-cell {
align-items: center;
display: inline-flex;
gap: 0.25em;
}
.wide-cell .button {
border: none;
font-size: inherit;
height: auto;
padding: 0;
}
.wide-cell button.link::after {
content: none;
}

View File

@@ -1,12 +1,13 @@
class MeasurementsController < ApplicationController class MeasurementsController < ApplicationController
before_action :find_readout, only: [:destroy, :edit, :update]
before_action except: :index do before_action except: :index do
raise AccessForbidden unless current_user.at_least(:active) raise AccessForbidden unless current_user.at_least(:active)
end end
def index def index
load_measurements readouts = current_user.readouts.includes(:quantity, :unit).order(created_at: :desc)
@measurements = readouts.group_by(&:created_at).map do |created_at, grouped|
Measurement.new(created_at: created_at, readouts: grouped)
end
end end
def new def new
@@ -14,49 +15,33 @@ class MeasurementsController < ApplicationController
end end
def create def create
taken_at = params.permit(:taken_at)[:taken_at] timestamp = Time.current
readout_params = params.permit(readouts: Readout::ATTRIBUTES).fetch(:readouts, []) @readouts = readout_params.map do |rp|
@readouts = readout_params.map { |rp| current_user.readouts.build(rp.merge(taken_at: taken_at)) } r = current_user.readouts.new(rp)
r.created_at = timestamp
if @readouts.present? && @readouts.all?(&:valid?) r
ActiveRecord::Base.transaction { @readouts.each(&:save!) }
load_measurements
flash.now[:notice] = t('.success', count: @readouts.size)
else
errors = @readouts.flat_map { |r| r.errors.full_messages }
flash.now[:alert] = errors.present? ? errors.first : t('.no_readouts')
end end
end
def edit if @readouts.all?(&:valid?)
@user_units = current_user.units.ordered Readout.transaction { @readouts.each(&:save!) }
end @measurement = Measurement.new(readouts: @readouts, created_at: timestamp)
def update
if @readout.update(params.require(:readout).permit(:value, :unit_id, :taken_at))
load_measurements
flash.now[:notice] = t('.success') flash.now[:notice] = t('.success')
else else
@user_units = current_user.units.ordered render :new, status: :unprocessable_entity
render :edit
end end
end end
def destroy def destroy
@readout.destroy! @measurement = Measurement.new(id: params[:id].to_i,
load_measurements created_at: Time.at(params[:id].to_i))
current_user.readouts.where(created_at: @measurement.created_at).delete_all
@measurements_empty = current_user.readouts.empty?
flash.now[:notice] = t('.success') flash.now[:notice] = t('.success')
end end
private private
def find_readout def readout_params
@readout = current_user.readouts.find(params[:id]) params.require(:readouts).map { |r| r.permit(:quantity_id, :value, :unit_id) }
end
def load_measurements
@measurements = current_user.readouts.includes(:quantity, :unit).order(taken_at: :desc, id: :desc)
@wide_groups = @measurements.group_by(&:taken_at)
@wide_quantities = @measurements.map(&:quantity).uniq.sort_by(&:name)
end end
end end

View File

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

View File

@@ -12,12 +12,6 @@ module ApplicationHelper
labeled_field_for(method, options) { super } labeled_field_for(method, options) { super }
end end
def submit(value = nil, options = {})
value, options = nil, value if value.is_a?(Hash)
options[:class] = @template.class_names('button', options[:class])
super
end
private private
def labeled_field_for(method, options) def labeled_field_for(method, options)
@@ -86,7 +80,6 @@ module ApplicationHelper
def initialize(...) def initialize(...)
super(...) super(...)
@default_options.merge!(@options.slice(:form)) @default_options.merge!(@options.slice(:form))
@default_html_options.merge!(@options.slice(:form))
end end
[:text_field, :password_field, :text_area].each do |selector| [:text_field, :password_field, :text_area].each do |selector|
@@ -103,28 +96,20 @@ module ApplicationHelper
def number_field(method, options = {}) def number_field(method, options = {})
attr_type = object.type_for_attribute(method) attr_type = object.type_for_attribute(method)
case attr_type.type if attr_type.type == :decimal
when :decimal
options[:value] = object.public_send(method)&.to_scientific options[:value] = object.public_send(method)&.to_scientific
options[:step] ||= BigDecimal(10).power(-attr_type.scale) options[:step] ||= BigDecimal(10).power(-attr_type.scale)
options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) - options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) -
options[:step] options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min] options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max] options[:min] ||= -options[:max]
options[:size] ||= attr_type.precision / 2
when :float
options[:size] ||= 6
end end
super super
end end
def button(value = nil, options = {}, &block) def button(value = nil, options = {}, &block)
# #button does not use #objectify_options/@default_options # button does not use #objectify_options
value, options = nil, value if value.is_a?(Hash) options.merge!(@options.slice(:form))
options = options.merge(
@default_options.slice(:form),
class: @template.class_names('button', options[:class])
)
super super
end end
@@ -153,14 +138,12 @@ module ApplicationHelper
end end
def tabular_form_with(**options, &block) def tabular_form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, class: 'tabular-form', extra_options = {builder: TabularFormBuilder, html: {autocomplete: 'off'}}
html: {autocomplete: 'off'}}
form_with(**merge_attributes(options, extra_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 = {})
label, options = nil, label if label.is_a? Hash svg_tag = tag.svg(options) do
svg_tag = tag.svg(**options) do
tag.use(href: "#{image_path(source + ".svg")}#icon") tag.use(href: "#{image_path(source + ".svg")}#icon")
end end
label.blank? ? svg_tag : svg_tag + tag.span(label) label.blank? ? svg_tag : svg_tag + tag.span(label)
@@ -229,8 +212,9 @@ module ApplicationHelper
# Conversion of flash to Array only required because of Devise # Conversion of flash to Array only required because of Devise
Array(messages).map do |message| Array(messages).map do |message|
tag.div class: "flash #{entry}" do tag.div class: "flash #{entry}" do
tag.span(sanitize(message)) + # TODO: change button text to svg to make it aligned vertically
svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"}) tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
onclick: "this.parentElement.remove();")
end end
end end
end.join.html_safe end.join.html_safe

View File

@@ -1,9 +1,9 @@
module QuantitiesHelper module QuantitiesHelper
def quantities_check_boxes(quantities) def quantities_check_boxes
# Closing <details> on focusout event depends on relatedTarget for internal # Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of # actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */ # ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
collection_check_boxes(nil, :quantity, quantities, :id, :to_s_with_depth, collection_check_boxes(nil, :quantity, @quantities, :id, :to_s_with_depth,
include_hidden: false) do |b| include_hidden: false) do |b|
content_tag :li, b.label(tabindex: -1) { b.check_box + b.text } content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
end end

View File

@@ -9,47 +9,6 @@ function showPage(event) {
} }
document.addEventListener('turbo:load', showPage) document.addEventListener('turbo:load', showPage)
function groupMeasurements() {
var tbody = document.getElementById('measurements');
if (!tbody) return;
var prevTakenAt = null;
Array.from(tbody.querySelectorAll('tr[data-taken-at]'))
.filter(function(row) { return row.style.display !== 'none' })
.forEach(function(row) {
var takenAt = row.dataset.takenAt;
row.classList.toggle('grouped', takenAt !== null && takenAt === prevTakenAt);
prevTakenAt = takenAt;
});
}
function getMeasurementsView() {
return localStorage.getItem('measurements-view') || 'compact';
}
function applyMeasurementsView(view) {
document.body.dataset.measurementsView = view;
}
function setMeasurementsView(view) {
localStorage.setItem('measurements-view', view);
applyMeasurementsView(view);
}
window.setMeasurementsView = setMeasurementsView
document.addEventListener('turbo:load', function() {
var tbody = document.getElementById('measurements');
if (!tbody) return;
groupMeasurements();
applyMeasurementsView(getMeasurementsView());
new MutationObserver(function() {
groupMeasurements();
}).observe(tbody, {
childList: true, subtree: true,
attributes: true, attributeFilter: ['style']
});
})
function detailsChange(event) { function detailsChange(event) {
var target = event.currentTarget var target = event.currentTarget
var count = target.querySelectorAll('input:checked:not([disabled])').length var count = target.querySelectorAll('input:checked:not([disabled])').length
@@ -78,41 +37,6 @@ window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true})) mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
}); });
function readoutUnitChanged(select) {
var button = select.closest('tr').querySelector('.set-default-unit');
if (select.value && select.value !== select.dataset.defaultUnitId) {
Turbo.StreamElement.prototype.enableElement(button);
} else {
Turbo.StreamElement.prototype.disableElement(button);
}
}
window.readoutUnitChanged = readoutUnitChanged
function setDefaultUnit(button) {
var select = button.closest('tr').querySelector('select[data-default-unit-id]');
var form = document.createElement('form');
form.action = button.dataset.path;
form.method = 'post';
form.dataset.turboStream = 'true';
var methodInput = document.createElement('input');
methodInput.type = 'hidden'; methodInput.name = '_method'; methodInput.value = 'patch';
var unitInput = document.createElement('input');
unitInput.type = 'hidden'; unitInput.name = 'quantity[default_unit_id]'; unitInput.value = select.value;
form.appendChild(methodInput);
form.appendChild(unitInput);
form.addEventListener('turbo:submit-end', function(event) {
if (event.detail.success) {
select.dataset.defaultUnitId = select.value;
readoutUnitChanged(select);
}
form.remove();
});
document.body.appendChild(form);
form.requestSubmit();
}
window.setDefaultUnit = setDefaultUnit
function formValidate(event) { function formValidate(event) {
var id = event.submitter.getAttribute("data-validate") var id = event.submitter.getAttribute("data-validate")
if (!id) return; if (!id) return;
@@ -282,17 +206,22 @@ window.dragEnd = dragEnd
function drop(event) { function drop(event) {
event.preventDefault() event.preventDefault()
var idParam = event.currentTarget.getAttribute("data-drop-id-param")
var params = new URLSearchParams()
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()
var form = document.createElement('form'); params.append(id_param, id)
form.action = event.dataTransfer.getData("text/plain");
form.method = 'post'; fetch(event.dataTransfer.getData("text/plain"), {
form.dataset.turboStream = 'true'; body: params,
var input = document.createElement('input'); headers: {
input.type = 'hidden'; input.name = idParam; input.value = id; "Accept": "text/vnd.turbo-stream.html",
form.appendChild(input); "X-CSRF-Token": document.head.querySelector("meta[name=csrf-token]").content,
form.addEventListener('turbo:submit-end', function() { form.remove(); }); "X-Requested-With": "XMLHttpRequest"
document.body.appendChild(form); },
form.requestSubmit(); method: "POST"
})
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html))
} }
window.drop = drop window.drop = drop

View File

@@ -1,3 +1,17 @@
class Measurement class Measurement
include ActiveModel::Model include ActiveModel::Model
attr_accessor :readouts, :created_at
def id
created_at.to_i
end
def to_param
id.to_s
end
def persisted?
true
end
end end

View File

@@ -1,10 +1,9 @@
class Quantity < ApplicationRecord class Quantity < ApplicationRecord
ATTRIBUTES = [:name, :description, :parent_id, :default_unit_id] ATTRIBUTES = [:name, :description, :parent_id]
attr_cached :depth, :pathname attr_cached :depth, :pathname
belongs_to :user, optional: true belongs_to :user, optional: true
belongs_to :parent, optional: true, class_name: "Quantity" belongs_to :parent, optional: true, class_name: "Quantity"
belongs_to :default_unit, optional: true, class_name: "Unit"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity", has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error inverse_of: :parent, dependent: :restrict_with_error
@@ -16,8 +15,8 @@ class Quantity < ApplicationRecord
errors.add(:parent, :descendant_reference) if ancestor_of?(parent) errors.add(:parent, :descendant_reference) if ancestor_of?(parent)
end end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]}, validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
length: {maximum: type_for_attribute(:name).limit} length: {maximum: type_for_attribute(:name).limit || Float::INFINITY}
validates :description, length: {maximum: type_for_attribute(:description).limit} validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY}
# Update :depths of progenies after parent change # Update :depths of progenies after parent change
before_save if: :parent_changed? do before_save if: :parent_changed? do
@@ -62,18 +61,26 @@ class Quantity < ApplicationRecord
# Return: ordered [sub]hierarchy # Return: ordered [sub]hierarchy
scope :ordered, ->(root: nil, include_root: true) { scope :ordered, ->(root: nil, include_root: true) {
numbered = Arel::Table.new('numbered') if connection.adapter_name =~ /mysql/i
numbered = Arel::Table.new('numbered')
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [ self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project( numbered.project(
numbered[Arel.star], numbered[Arel.star],
numbered.cast(numbered[:child_number], 'BINARY').as('path') numbered.cast(numbered[:child_number], 'BINARY').as('path')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)), ).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project( numbered.project(
numbered[Arel.star], numbered[Arel.star],
arel_table[:path].concat(numbered[:child_number]) arel_table[:path].concat(numbered[:child_number])
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id])) ).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path]) ]).order(arel_table[:path])
elsif root.nil?
# SQLite: pathname column already stores the full hierarchical path
order(:pathname)
else
root_pathname = unscoped.where(id: root).pick(:pathname)
scope = order(:pathname).where("pathname LIKE ?", "#{root_pathname}#{PATHNAME_DELIMITER}%")
include_root ? scope.or(where(id: root)) : scope
end
} }
# TODO: extract named functions to custom Arel extension # TODO: extract named functions to custom Arel extension

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
class: 'tools-area' %> class: 'tools-area' %>
</div> </div>
<table class="main-area items-table"> <table class="main-area items">
<thead> <thead>
<tr> <tr>
<th><%= Unit.human_attribute_name(:symbol) %></th> <th><%= Unit.human_attribute_name(:symbol) %></th>

View File

@@ -23,10 +23,10 @@
</head> </head>
<body> <body>
<header class="flex"> <header class="hflex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %> <%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url, <%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "rextend" %> class: "extendedright" %>
<% if user_signed_in? %> <% if user_signed_in? %>
<%= image_link_to_unless_current(current_user, "account-wrench-outline", <%= image_link_to_unless_current(current_user, "account-wrench-outline",
edit_user_registration_path) %> edit_user_registration_path) %>

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
<% form_tag = dom_id(@readout, :edit, :form) %>
<% row = dom_id(@readout, :edit) %>
<% hidden_row = dom_id(@readout) %>
<%= tabular_form_with model: @readout, url: measurement_path(@readout),
id: form_tag do |form| %>
<table class="items-table">
<tbody>
<%= tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {form: form_tag, hidden_row: hidden_row} do %>
<td><%= @readout.quantity %></td>
<td class="ralign">
<%= form.number_field :value, required: true, autofocus: true %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id? ? 1 : 0) + u.symbol) },
{}, required: true %>
</td>
<td>
<%= form.datetime_field :taken_at %>
</td>
<td></td>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", measurements_path,
class: 'dangerous', name: :cancel,
onclick: render_turbo_stream('edit_form_close', {row: row}) %>
</td>
<% end %>
</tbody>
</table>
<% end %>

View File

@@ -1,37 +1,28 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form, <%= tabular_form_with model: Measurement.new, id: :measurement_form,
class: 'topside-area flex vertical center', class: 'topside-area vflex', html: {onkeydown: 'formProcessKey(event)'} do |form| %>
html: {onkeydown: 'formProcessKey(event)'} do |form| %> <table class="items centered">
<tbody id="readouts"></tbody>
<table class="items-table center">
<tbody id="readouts">
<%= tabular_fields_for @measurement do |form| %>
<tr class="italic">
<td class="hexpand hmin50"><%= t '.taken_at_html' %></td>
<td colspan="3" class="ralign">
<%= form.datetime_field :taken_at, required: true, value: Time.current.strftime('%Y-%m-%dT%H:%M') %>
</td>
</tr>
<% end %>
</tbody>
</table> </table>
<%# TODO: right-click selection; unnecessary with hierarchical tags? %> <div class="hflex">
<details id="quantity_select" class="center hexpand" open <%# TODO: right-click selection %>
onkeydown="detailsProcessKey(event)"> <details id="quantity_select" class="hexpand" open
<summary autofocus> onkeydown="detailsProcessKey(event)">
<!-- TODO: Set content with CSS when span empty to avoid duplication --> <summary autofocus>
<span data-prompt="<%= t('.select_quantity') %>"> <!-- TODO: Set content with CSS when span empty to avoid duplication -->
<%= t('.select_quantity') %> <span data-prompt="<%= t('.select_quantity') %>">
</span> <%= t('.select_quantity') %>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true, </span>
formaction: new_readout_path, formmethod: :get, formnovalidate: true, <%= image_button_tag t(:apply), "update", name: nil, disabled: true,
data: {turbo_stream: true} %> formaction: new_readout_path, formmethod: :get, formnovalidate: true,
</summary> data: {turbo_stream: true} %>
<ul><%= quantities_check_boxes(@quantities) %></ul> </summary>
</details> <ul><%= quantities_check_boxes %></ul>
</details>
<div class="flex reverse">
<%= form.button id: :create_measurement_button, disabled: true -%> <%= form.button id: :create_measurement_button, disabled: true -%>
</div>
<div class="hflex reverse">
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel, <%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %> class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div> </div>

View File

@@ -0,0 +1,14 @@
<%= tag.tr id: dom_id(measurement) do %>
<td><%= l measurement.created_at, format: :short %></td>
<td>
<% measurement.readouts.each do |readout| %>
<span><%= readout.quantity.name %>: <%= readout.value %> <%= readout.unit %></span>
<% end %>
</td>
<% if current_user.at_least(:active) %>
<td class="actions">
<%= image_button_to t('.destroy'), 'delete-outline', measurement_path(measurement),
method: :delete %>
</td>
<% end %>
<% end %>

View File

@@ -1,22 +0,0 @@
<%= tag.tr id: dom_id(readout), data: {taken_at: readout.taken_at&.iso8601,
quantity_id: readout.quantity_id, quantity_name: readout.quantity.name,
value: format("%.10g", readout.value), unit: readout.unit.symbol} do %>
<td>
<% if current_user.at_least(:active) %>
<%= link_to readout.quantity, edit_measurement_path(readout),
class: 'link', onclick: 'this.blur();', data: {turbo_stream: true} %>
<% else %>
<%= readout.quantity %>
<% end %>
</td>
<td class="ralign"><%= format("%.10g", readout.value) %></td>
<td><%= readout.unit %></td>
<td class="taken-at"><%= l(readout.taken_at) if readout.taken_at %></td>
<td class="created-at"><%= l(readout.created_at) %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<%= image_button_to t('.destroy'), 'delete-outline', measurement_path(readout),
method: :delete, data: {turbo_stream: true} %>
</td>
<% end %>
<% end %>

View File

@@ -1,41 +0,0 @@
<table class="items-table">
<thead>
<tr>
<th><%= Readout.human_attribute_name(:taken_at) %></th>
<% wide_quantities.each do |q| %>
<th><%= q.name %></th>
<% end %>
<th><%= Readout.human_attribute_name(:created_at) %></th>
</tr>
</thead>
<tbody>
<% wide_groups.each do |taken_at, readouts| %>
<tr>
<td><%= l(taken_at) if taken_at %></td>
<% wide_quantities.each do |q| %>
<% readout = readouts.find { |r| r.quantity_id == q.id } %>
<td class="ralign">
<% if readout %>
<span class="wide-cell">
<% if current_user.at_least(:active) %>
<%= link_to format("%.10g", readout.value),
edit_measurement_path(readout, view: :wide),
class: 'link', onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% else %>
<%= format("%.10g", readout.value) %>
<% end %>
&nbsp;<%= readout.unit.symbol %>
<% if current_user.at_least(:active) %>
<%= image_button_to '', 'delete-outline', measurement_path(readout),
method: :delete, data: {turbo_stream: true} %>
<% end %>
</span>
<% end %>
</td>
<% end %>
<td><%= l(readouts.first.created_at) %></td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -1,13 +1,5 @@
<% if @readouts.present? && @readouts.all?(&:persisted?) %> <%= turbo_stream.update :flashes %>
<%= turbo_stream.update :flashes %> <%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.remove :measurement_form %> <%= turbo_stream.remove :no_items %>
<%= turbo_stream.enable :new_measurement_link %> <%= turbo_stream.enable :new_measurement_link %>
<%= turbo_stream.remove :no_items %> <%= turbo_stream.prepend :measurements, @measurement %>
<% @readouts.each do |readout| %>
<%= turbo_stream.prepend :measurements, partial: 'readout', locals: {readout: readout} %>
<% end %>
<%= turbo_stream.update 'measurements-wide', partial: 'wide_table',
locals: {wide_groups: @wide_groups, wide_quantities: @wide_quantities} %>
<% else %>
<%= turbo_stream.update :flashes %>
<% end %>

View File

@@ -1,5 +1,3 @@
<%= turbo_stream.update :flashes %> <%= turbo_stream.update :flashes %>
<%= turbo_stream.remove @readout %> <%= turbo_stream.remove @measurement %>
<%= turbo_stream.append(:measurements, render_no_items) if current_user.readouts.empty? %> <%= turbo_stream.append(:measurements, render_no_items) if @measurements_empty %>
<%= turbo_stream.update 'measurements-wide', partial: 'wide_table',
locals: {wide_groups: @wide_groups, wide_quantities: @wide_quantities} %>

View File

@@ -1,18 +0,0 @@
<% ids = {row: dom_id(@readout, :edit),
hidden_row: dom_id(@readout),
link: nil,
form_tag: dom_id(@readout, :edit, :form)} %>
<% if params[:view] == 'wide' %>
<%= turbo_stream.update :measurement_edit_form, partial: 'edit_panel' %>
<%= turbo_stream.hide ids[:hidden_row] %>
<% else %>
<%= turbo_stream.append :measurement_edit_form do %>
<%- tabular_form_with model: @readout, url: measurement_path(@readout),
html: {id: ids[:form_tag]} do %>
<% end %>
<% end %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @readout, partial: 'edit_form', locals: ids -%>
<% end %>

View File

@@ -5,36 +5,10 @@
id: :new_measurement_link, onclick: 'this.blur();', id: :new_measurement_link, onclick: 'this.blur();',
data: {turbo_stream: true} %> data: {turbo_stream: true} %>
<% end %> <% end %>
<%= image_button_tag '', 'view-rows', name: nil, type: 'button',
class: 'view-toggle', title: t('.view_compact'),
data: {view: 'compact'}, onclick: "setMeasurementsView('compact')" %>
<%= image_button_tag '', 'view-columns', name: nil, type: 'button',
class: 'view-toggle', title: t('.view_wide'),
data: {view: 'wide'}, onclick: "setMeasurementsView('wide')" %>
</div>
<div class="main-area measurements-section">
<%= tag.div id: :measurement_edit_form %>
<table class="items-table measurements-compact">
<thead>
<tr>
<th><%= Quantity.model_name.human %></th>
<th><%= Readout.human_attribute_name(:value) %></th>
<th><%= Unit.model_name.human %></th>
<th data-column="taken-at"><%= Readout.human_attribute_name(:taken_at) %></th>
<th data-column="created-at"><%= Readout.human_attribute_name(:created_at) %></th>
<% if current_user.at_least(:active) %>
<th></th>
<% end %>
</tr>
</thead>
<tbody id="measurements">
<%= render(partial: 'readout', collection: @measurements, as: :readout) || render_no_items %>
</tbody>
</table>
<div id="measurements-wide" class="measurements-wide">
<%= render 'wide_table', wide_groups: @wide_groups, wide_quantities: @wide_quantities %>
</div>
</div> </div>
<table class="main-area">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>
</table>

View File

@@ -1,4 +0,0 @@
<%= turbo_stream.close_form dom_id(@readout, :edit) %>
<%= turbo_stream.replace @readout, partial: 'measurements/readout', locals: {readout: @readout} %>
<%= turbo_stream.update 'measurements-wide', partial: 'wide_table',
locals: {wide_groups: @wide_groups, wide_quantities: @wide_quantities} %>

View File

@@ -8,13 +8,8 @@
<td> <td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %> <%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td> </td>
<td>
<%= form.collection_select :default_unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id? ? 1 : 0) + u.symbol) },
{include_blank: true}, onchange: "this.dataset.changed = ''" %>
</td>
<td class="flex"> <td class="actions">
<%= form.button %> <%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous', <%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %> name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>

View File

@@ -5,15 +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 style="--depth:<%= quantity.depth %>"> <td class="link" style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link', <%= link_to quantity, edit_quantity_path(quantity), onclick: 'this.blur();',
onclick: 'this.blur();', data: {turbo_stream: true} %> data: {turbo_stream: true} %>
</td> </td>
<td><%= quantity.description %></td> <td><%= quantity.description %></td>
<td><%= quantity.default_unit&.symbol %></td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="flex"> <td class="actions">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity), <%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %> id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>

View File

@@ -8,15 +8,13 @@
class: 'tools-area' %> class: 'tools-area' %>
</div> </div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div class: 'main-area', id: :quantity_form %> <%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main-area items-table"> <table class="main-area items">
<thead> <thead>
<tr> <tr>
<th><%= Quantity.human_attribute_name(:name) %></th> <th><%= Quantity.human_attribute_name(:name) %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th> <th><%= Quantity.human_attribute_name(:description) %></th>
<th><%= Quantity.human_attribute_name(:default_unit) %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t :actions %></th> <th><%= t :actions %></th>
<th></th> <th></th>
@@ -26,7 +24,7 @@
ondragover: "dragOver(event)", ondrop: "drop(event)", ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)", ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %> data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
<th colspan="5"><%= t '.top_level_drop' %></th> <th colspan="4"><%= t '.top_level_drop' %></th>
<% end %> <% end %>
</thead> </thead>
<tbody id="quantities"> <tbody id="quantities">

View File

@@ -1,31 +1,25 @@
<%# TODO: add readout reordering by dragging %> <%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %> <%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %> <%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<td> <td class="actions">
<%# 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: readout.quantity.default_unit_id || ''}, required: true,
data: {default_unit_id: readout.quantity.default_unit_id || ''},
onchange: "readoutUnitChanged(this)" %>
</td>
<td class="flex">
<%# TODO: change to _link_ after giving up displaying relative paths %> <%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'check-circle-outline',
class: 'set-default-unit', name: nil, type: 'button', disabled: true,
title: t('readouts.form.set_default_unit'),
data: {path: quantity_path(readout.quantity)},
onclick: 'setDefaultUnit(this)' %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil, <%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity), formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %> formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td> </td>
<td>
<%= readout.quantity.relative_pathname(@superquantity) %>
</td>
<td>
<%= form.number_field :value, required: true,
size: readout.type_for_attribute(:value).precision / 2,
autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.hidden_field :quantity_id %>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: t('.select_unit'), disabled: '', selected: ''}, required: true %>
</td>
<% end %> <% end %>
<% end %> <% end %>

View File

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

View File

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

View File

@@ -7,14 +7,13 @@
class: 'tools-area' %> class: 'tools-area' %>
</div> </div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div id: :unit_form %> <%= tag.div id: :unit_form %>
<table class="main-area items-table"> <table class="main-area items">
<thead> <thead>
<tr> <tr>
<th><%= Unit.human_attribute_name(:symbol) %></th> <th><%= Unit.human_attribute_name(:symbol) %></th>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %></th> <th><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></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>

View File

@@ -1,4 +1,4 @@
<table class="main-area items-table" id="users"> <table class="main-area items" id="users">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:email) %></th> <th><%= User.human_attribute_name(:email) %></th>
@@ -11,7 +11,7 @@
<tbody> <tbody>
<% @users.each do |user| %> <% @users.each do |user| %>
<tr> <tr>
<td><%= link_to user, user_path(user), class: 'link' %></td> <td class="link"><%= link_to user, user_path(user) %></td>
<td> <td>
<% if user == current_user %> <% if user == current_user %>
<%= user.status %> <%= user.status %>
@@ -22,11 +22,11 @@
<% end %> <% end %>
<% end %> <% end %>
</td> </td>
<td> <td class="svg">
<%= svg_tag 'pictograms/checkbox-marked-outline' if user.confirmed_at.present? %> <%= svg_tag 'pictograms/checkbox-marked-outline' if user.confirmed_at.present? %>
</td> </td>
<td><%= l user.created_at, format: :without_tz %></td> <td><%= l user.created_at, format: :without_tz %></td>
<td class="flex"> <td class="actions">
<% if allow_disguise?(user) %> <% if allow_disguise?(user) %>
<%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %> <%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %>
<% end %> <% end %>

View File

@@ -10,6 +10,7 @@
<%= f.submit t(:register), data: {turbo: false} %> <%= f.submit t(:register), data: {turbo: false} %>
<%# TODO: fix button text color after change link -> button %>
<%= image_button_tag t(:resend_confirmation), 'email-sync-outline', <%= image_button_tag t(:resend_confirmation), 'email-sync-outline',
class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true, class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %> data: {validate: f.field_id(:email)} %>

View File

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

View File

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

View File

@@ -11,13 +11,8 @@ en:
activerecord: activerecord:
attributes: attributes:
quantity: quantity:
default_unit: Default unit
description: Description description: Description
name: Name name: Name
readout:
created_at: Recorded at
taken_at: Taken at
value: Value
unit: unit:
base: Base unit base: Base unit
description: Description description: Description
@@ -86,31 +81,22 @@ en:
revert: Revert revert: Revert
sign_out: Sign out sign_out: Sign out
source_code: Get code source_code: Get code
readouts:
form:
set_default_unit: Set as default unit
measurements: measurements:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now. no_items: There are no measurements taken. You can Add some now.
form: form:
select_quantity: select quantities... select_quantity: select the measured quantities...
taken_at_html: Measurement taken at&emsp;
index: index:
new_measurement: Add measurement new_measurement: Add measurement
view_compact: Compact view
view_wide: Wide view
readout:
edit: Edit
destroy: Delete
create: create:
success: success: Measurement saved.
one: Recorded 1 measurement.
other: Recorded %{count} measurements.
no_readouts: No readouts selected.
update:
success: Measurement updated.
destroy: destroy:
success: Measurement deleted. success: Measurement deleted.
measurement:
destroy: Delete
readouts:
form:
select_unit: ...
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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,64 +0,0 @@
require "application_system_test_case"
class MeasurementsTest < ApplicationSystemTestCase
setup do
@user = sign_in(user: users(:alice))
@quantity = @user.quantities.create!(name: 'Weight')
@unit = @user.units.create!(symbol: 'kg')
@readout = @user.readouts.create!(quantity: @quantity, unit: @unit, value: 82.5)
visit measurements_path
end
test "index shows quantity name as edit link for active user" do
within 'tbody' do
assert_selector :link, exact_text: @quantity.name
end
end
test "edit opens inline form on quantity link click" do
within 'tbody' do
click_on @quantity.name
assert_selector ':focus'
assert_selector 'input[name="readout[value]"]'
end
end
test "edit and update measurement value" do
within 'tbody' do
click_on @quantity.name
fill_in 'readout[value]', with: '83.1'
assert_difference ->{ @readout.reload.value }, 83.1 - @readout.value do
click_on t('helpers.submit.update')
end
assert_no_selector :fillable_field
assert_selector :link, exact_text: @quantity.name
end
assert_selector '.flash.notice', text: t('measurements.update.success')
end
test "cancel edit restores original row" do
within 'tbody' do
click_on @quantity.name
assert_selector 'input[name="readout[value]"]'
click_on t(:cancel)
assert_no_selector :fillable_field
assert_selector :link, exact_text: @quantity.name
end
end
test "wide view edit opens panel form" do
@readout.update!(taken_at: Time.now)
visit measurements_path
execute_script("localStorage.removeItem('measurements-view')")
visit measurements_path
find('button[data-view="wide"]').click
within '#measurements-wide' do
assert_text format("%.10g", 82.5), wait: 3
find('button.link').click
end
assert_selector '#measurement_edit_form input[name="readout[value]"]', wait: 5
end
end

View File

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

View File

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