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