diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..bf935f0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,84 @@
+# 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
diff --git a/app/controllers/quantities_controller.rb b/app/controllers/quantities_controller.rb
index 5c0c5ba..b283aa2 100644
--- a/app/controllers/quantities_controller.rb
+++ b/app/controllers/quantities_controller.rb
@@ -8,6 +8,10 @@ class QuantitiesController < ApplicationController
raise AccessForbidden unless current_user.at_least(:active)
end
+ before_action only: [:new, :edit, :create, :update] do
+ @user_units = current_user.units.ordered
+ end
+
def index
@quantities = current_user.quantities.ordered.includes(:parent, :subquantities)
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index c78608e..0dea454 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -86,6 +86,7 @@ module ApplicationHelper
def initialize(...)
super(...)
@default_options.merge!(@options.slice(:form))
+ @default_html_options.merge!(@options.slice(:form))
end
[:text_field, :password_field, :text_area].each do |selector|
diff --git a/app/models/quantity.rb b/app/models/quantity.rb
index ed81124..3c3d61b 100644
--- a/app/models/quantity.rb
+++ b/app/models/quantity.rb
@@ -1,9 +1,10 @@
class Quantity < ApplicationRecord
- ATTRIBUTES = [:name, :description, :parent_id]
+ ATTRIBUTES = [:name, :description, :parent_id, :default_unit_id]
attr_cached :depth, :pathname
belongs_to :user, optional: true
belongs_to :parent, optional: true, class_name: "Quantity"
+ belongs_to :default_unit, optional: true, class_name: "Unit"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error
diff --git a/app/models/readout.rb b/app/models/readout.rb
index f50037c..37f52d5 100644
--- a/app/models/readout.rb
+++ b/app/models/readout.rb
@@ -1,5 +1,5 @@
class Readout < ApplicationRecord
- ATTRIBUTES = [:quantity_id, :value, :unit_id]
+ ATTRIBUTES = [:quantity_id, :value, :unit_id, :taken_at]
belongs_to :user
belongs_to :quantity
diff --git a/app/views/measurements/_form.html.erb b/app/views/measurements/_form.html.erb
index cd4b734..f8db9f9 100644
--- a/app/views/measurements/_form.html.erb
+++ b/app/views/measurements/_form.html.erb
@@ -8,7 +8,7 @@
| <%= t '.taken_at_html' %> |
- <%= form.datetime_field :taken_at, required: true %>
+ <%= form.datetime_field :taken_at, required: true, value: Time.current.strftime('%Y-%m-%dT%H:%M') %>
|
<% end %>
diff --git a/app/views/quantities/_form.html.erb b/app/views/quantities/_form.html.erb
index 15686a1..99c58e2 100644
--- a/app/views/quantities/_form.html.erb
+++ b/app/views/quantities/_form.html.erb
@@ -8,6 +8,11 @@
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
|
+
+ <%= form.collection_select :default_unit_id, @user_units, :id,
+ ->(u){ sanitize(' ' * (u.base_id? ? 1 : 0) + u.symbol) },
+ {include_blank: true}, onchange: "this.dataset.changed = ''" %>
+ |
<%= form.button %>
diff --git a/app/views/quantities/_quantity.html.erb b/app/views/quantities/_quantity.html.erb
index 43344e2..edec53e 100644
--- a/app/views/quantities/_quantity.html.erb
+++ b/app/views/quantities/_quantity.html.erb
@@ -10,6 +10,7 @@
onclick: 'this.blur();', data: {turbo_stream: true} %>
|
<%= quantity.description %> |
+ <%= quantity.default_unit&.symbol %> |
<% if current_user.at_least(:active) %>
diff --git a/app/views/quantities/index.html.erb b/app/views/quantities/index.html.erb
index ecd3258..f82b49a 100644
--- a/app/views/quantities/index.html.erb
+++ b/app/views/quantities/index.html.erb
@@ -16,6 +16,7 @@
|
| <%= Quantity.human_attribute_name(:name) %> |
<%= Quantity.human_attribute_name(:description) %> |
+ <%= Quantity.human_attribute_name(:default_unit) %> |
<% if current_user.at_least(:active) %>
<%= t :actions %> |
|
@@ -25,7 +26,7 @@
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
- <%= t '.top_level_drop' %> |
+ <%= t '.top_level_drop' %> |
<% end %>
diff --git a/app/views/readouts/_form.html.erb b/app/views/readouts/_form.html.erb
index 79d1293..0f2dfd8 100644
--- a/app/views/readouts/_form.html.erb
+++ b/app/views/readouts/_form.html.erb
@@ -12,10 +12,17 @@
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize(' ' * (u.base_id ? 1 : 0) + u.symbol) },
- {prompt: '', disabled: '', selected: ''}, required: true %>
+ {prompt: '', disabled: '', selected: readout.quantity.default_unit_id || ''}, required: true,
+ data: {default_unit_id: readout.quantity.default_unit_id || ''},
+ onchange: "readoutUnitChanged(this)" %>
|
<%# 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,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 857297d..7325b16 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -58,4 +58,7 @@ Rails.application.configure do
# config.action_view.annotate_rendered_view_with_filenames = true
config.log_level = :info
+
+ # Allow Capybara's dynamic test server host (127.0.0.1:)
+ config.hosts << '127.0.0.1'
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 411a2a1..b8fd2db 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -11,10 +11,12 @@ en:
activerecord:
attributes:
quantity:
+ default_unit: Default unit
description: Description
name: Name
readout:
created_at: Recorded at
+ taken_at: Taken at
value: Value
unit:
base: Base unit
@@ -84,6 +86,9 @@ en:
revert: Revert
sign_out: Sign out
source_code: Get code
+ readouts:
+ form:
+ set_default_unit: Set as default unit
measurements:
navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
diff --git a/db/migrate/20260402000000_add_taken_at_to_readouts.rb b/db/migrate/20260402000000_add_taken_at_to_readouts.rb
new file mode 100644
index 0000000..c257679
--- /dev/null
+++ b/db/migrate/20260402000000_add_taken_at_to_readouts.rb
@@ -0,0 +1,5 @@
+class AddTakenAtToReadouts < ActiveRecord::Migration[7.2]
+ def change
+ add_column :readouts, :taken_at, :datetime
+ end
+end
diff --git a/db/migrate/20260403000000_add_default_unit_to_quantities.rb b/db/migrate/20260403000000_add_default_unit_to_quantities.rb
new file mode 100644
index 0000000..09b0c9a
--- /dev/null
+++ b/db/migrate/20260403000000_add_default_unit_to_quantities.rb
@@ -0,0 +1,5 @@
+class AddDefaultUnitToQuantities < ActiveRecord::Migration[7.2]
+ def change
+ add_reference :quantities, :default_unit, foreign_key: {to_table: :units}, null: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3975ec5..f8b27bc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
+ActiveRecord::Schema[7.2].define(version: 2026_04_03_000000) do
create_table "quantities", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id"
t.string "name", limit: 31, null: false
@@ -20,6 +20,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false
+ t.bigint "default_unit_id"
+ t.index ["default_unit_id"], name: "index_quantities_on_default_unit_id"
t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id"
@@ -32,6 +34,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
t.decimal "value", precision: 30, scale: 15, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.datetime "taken_at"
t.index ["quantity_id", "created_at"], name: "index_readouts_on_quantity_id_and_created_at", unique: true
t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id"], name: "index_readouts_on_unit_id"
@@ -70,6 +73,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
end
add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
+ add_foreign_key "quantities", "units", column: "default_unit_id"
add_foreign_key "quantities", "users"
add_foreign_key "readouts", "quantities"
add_foreign_key "readouts", "units"
diff --git a/test/system/quantities_test.rb b/test/system/quantities_test.rb
new file mode 100644
index 0000000..942e59b
--- /dev/null
+++ b/test/system/quantities_test.rb
@@ -0,0 +1,45 @@
+require "application_system_test_case"
+
+class QuantitiesTest < ApplicationSystemTestCase
+ setup do
+ @user = sign_in(user: users(:alice))
+ @unit = @user.units.create!(symbol: 'kg')
+ @quantity = @user.quantities.create!(name: 'Weight')
+ visit quantities_path
+ end
+
+ test "update button turns red when default unit changes" do
+ click_on 'Weight'
+
+ button = find('button[name=button]')
+ initial_color = evaluate_script("getComputedStyle(arguments[0]).backgroundColor", button)
+
+ select 'kg', from: 'quantity[default_unit_id]'
+
+ changed_color = evaluate_script("getComputedStyle(arguments[0]).backgroundColor", button)
+ refute_equal initial_color, changed_color, "Button color should change when default unit is altered"
+ end
+
+ test "saving default unit pre-selects it in measurements form" do
+ click_on 'Weight'
+ select 'kg', from: 'quantity[default_unit_id]'
+ click_on t('helpers.submit.update')
+ assert_selector '.flash.notice'
+
+ @quantity.reload
+ assert_equal @unit.id, @quantity.default_unit_id
+
+ visit measurements_path
+ find(:link_or_button, t('measurements.index.new_measurement')).click
+ assert_selector '#measurement_form'
+
+ within '#quantity_select' do
+ check 'Weight'
+ end
+ find('button[formaction]').click
+
+ within 'tbody#readouts' do
+ assert_selector "option[value='#{@unit.id}'][selected]"
+ end
+ end
+end
diff --git a/test/system/users_test.rb b/test/system/users_test.rb
index 070c514..2a5eab3 100644
--- a/test/system/users_test.rb
+++ b/test/system/users_test.rb
@@ -229,7 +229,7 @@ class UsersTest < ApplicationSystemTestCase
user = User.find_by_email!(first(:link).text)
inject_button_to first('td:not(.link)'), "update status", user_path(user), method: :patch,
params: {user: {status: User.statuses.keys.sample}}, data: {turbo: false}
- click_on "update status"
+ execute_script("arguments[0].click()", find_button("update status"))
end
assert_title 'The change you wanted was rejected (422)'
end
|