Compare commits

..

2 Commits

Author SHA1 Message Date
233cea022a Fix usability bugs and USDA importer performance
All checks were successful
parsley/pipeline/head This commit looks good
- Remove duplicate Save buttons in log creator/editor (fired with null data before recipe loaded)
- Redirect to new resource after creating recipe/log instead of dropping back to list
- Fix TheFoodCreator Cancel linking to dead route /food → /foods
- Refactor AppSearchText to use defineModel; fix search box not initializing from URL
- Fix TheCalculator variable shadowing bug (ingredient ref never updated on food select)
- Refactor UsdaImporter to use insert_all! instead of per-record save! (~240k branded foods)
- Fix string-based ndbn min comparison in build_enumerator (fragile on non-padded IDs)
- Add CLAUDE.md with project overview and architecture notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 14:42:22 -05:00
df99f800b9 Dep upgrades; bug fixes 2026-04-20 13:46:42 -05:00
24 changed files with 422 additions and 308 deletions

View File

@ -7,3 +7,4 @@ public/packs
node_modules/
.yarn
.pnp.*
.claude

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ yarn-debug.log*
.yarn-integrity
.yarn
.pnp.*
.claude

View File

@ -1 +1 @@
3.3.5
4.0.2

73
CLAUDE.md Normal file
View File

@ -0,0 +1,73 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Parsley is a personal recipe manager and meal planning app. It is a monolithic Rails 7.2 application with a Vue 3 SPA frontend.
**Backend:** Ruby 4.0.2, Rails 8.1.3, SQLite (dev/test), PostgreSQL (production)
**Frontend:** Vue 3 (Composition API / `<script setup>`), Pinia, Vue Router 4, Bulma CSS
**Asset bundling:** Shakapacker 8 (Webpack 5 + SWC)
**Testing:** RSpec, FactoryBot, Guard
## Common Commands
```bash
# Development
foreman start # Start Rails + Shakapacker dev server together
# Dependencies
bundle install && yarn install
# Database
bundle exec rake db:create db:migrate db:seed
bundle exec rake db:reset # Drop, recreate, seed
# Tests
bundle exec rspec # All tests
bundle exec rspec spec/models/recipe_spec.rb # Single file
bundle exec rspec spec/models/recipe_spec.rb:42 # Single test by line
bundle exec guard # Watch mode
# Build
bundle exec rake shakapacker:compile # Compile assets for production
```
**Test environment note:** Set `FAST=true` to skip FactoryBot linting on startup.
## Architecture
### Backend
**Models follow an `Ingredient` abstraction:** `Recipe` and `Food` both inherit from an abstract `Ingredient` base class. This allows recipes to contain both foods and other recipes as ingredients (`RecipeIngredient` is a polymorphic join). Polymorphic ingredient IDs are encoded as strings: `F{id}` for foods, `R{id}` for recipes.
**Custom unit conversion library** (`lib/unit_conversion/`): The `UnitConversion` module handles all quantity parsing and conversion. It is density-aware (can convert volume ↔ mass if food density is known) and supports custom per-food units defined in `FoodUnit` records. Key entry points: `UnitConversion.parse`, `UnitConversion.auto_unit`, `UnitConversion.with_custom_units`.
**Recipe scaling:** Recipes can be scaled by a factor and converted between unit systems (standard/metric) and unit types (mass/volume). Cache keys incorporate scale and unit system: `recipes/{id}/{scale}/{system}/{unit}`. Memcache is optional; enable with `RAILS_USE_MEMCACHE=true`.
**USDA nutrition data:** The `UsdaImporter` (lib/) imports from `vendor/data/usda/` during `db:seed`. `UsdaFood`/`UsdaFoodWeight` tables are a read-only cache; `Food` records link to them and inherit nutritional data.
**Serializers** (`app/serializers/`) handle all JSON API responses.
### Frontend
Vue 3 components live in `app/javascript/components/`. They use `<script setup>` Composition API style. Pinia stores (`app/javascript/stores/`) hold global state. Vue Router (`app/javascript/router/`) manages navigation.
Page-level components are named `The*` (e.g., `TheRecipeList`, `TheFood`, `TheCalculator`). Shared UI primitives are named `App*` (e.g., `AppModal`, `AppAutocomplete`, `AppIcon`).
### Key Environment Variables
| Variable | Purpose |
|----------|---------|
| `RAILS_USE_MEMCACHE` | Enable Memcache caching |
| `RAILS_MEMCACHE_HOST` | Memcache host (default: `memcache`) |
| `PARSLEY_DB_HOST/USER/PG_PASSWORD/DB_NAME` | Production PostgreSQL config |
### Docker
`docker-compose up` starts PostgreSQL, Memcached, Nginx, and two Rails service instances. See `docker-compose.yml` and `Dockerfile`.
### Seed Data
Default dev user: username `dan`, password `qwerty`. Comes with sample recipes and foods.

View File

@ -1,8 +1,9 @@
FROM ruby:3.3.5-bookworm
FROM ruby:4.0.2-trixie
RUN curl -sL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get update && apt-get dist-upgrade -y && \
apt-get install -y \
pkg-config \
nodejs \
nginx && \
apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/*

View File

@ -1,6 +1,6 @@
source 'https://rubygems.org'
gem 'rails', '7.2.1'
gem 'rails', '8.1.3'
gem 'pg', '~> 1.5.8'
gem 'shakapacker', '8.0.2'

View File

@ -1,106 +1,112 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
action_text-trix (2.1.18)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.2.1)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.1)
activesupport (= 7.2.1)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.1)
activesupport (= 7.2.1)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (7.2.1)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.8)
bootsnap (1.18.4)
uri (>= 0.13.1)
base64 (0.3.0)
bcrypt (3.1.22)
bigdecimal (4.1.2)
bootsnap (1.23.0)
msgpack (~> 1.2)
builder (3.3.0)
coderay (1.1.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
csv (3.3.0)
csv (3.3.5)
dalli (3.2.8)
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.2.0)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.3.4)
diff-lcs (1.5.1)
drb (2.2.1)
erubi (1.13.0)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
date (3.5.1)
diff-lcs (1.6.2)
drb (2.2.3)
erb (6.0.3)
erubi (1.13.1)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.4.4)
factory_bot (~> 6.5)
railties (>= 5.0.0)
ffi (1.17.0)
formatador (1.1.0)
globalid (1.2.1)
ffi (1.17.4)
formatador (1.2.3)
reline
globalid (1.3.0)
activesupport (>= 6.1)
guard (2.18.1)
formatador (>= 0.2.4)
@ -116,12 +122,15 @@ GEM
guard (~> 2.1)
guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0)
i18n (1.14.6)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
io-console (0.7.2)
irb (1.14.1)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.19.4)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@ -135,124 +144,136 @@ GEM
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
liner (0.2.4)
listen (3.9.0)
listen (3.10.0)
logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.1)
loofah (2.22.0)
logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
lumberjack (1.2.10)
mail (2.8.1)
lumberjack (1.4.2)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
marcel (1.1.0)
memoizable (0.5.1)
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.25.1)
msgpack (1.7.2)
mini_portile2 (2.8.9)
minitest (6.0.4)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
nenv (0.3.0)
net-imap (0.4.16)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.0)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.7)
nio4r (2.7.5)
nokogiri (1.19.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
notiffany (0.1.3)
nenv (~> 0.1)
shellany (~> 0.0)
oj (3.16.6)
oj (3.16.17)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
ostruct (0.6.0)
package_json (0.1.0)
ostruct (0.6.3)
package_json (0.2.0)
parslet (2.0.0)
pg (1.5.8)
pry (0.14.2)
pg (1.5.9)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
psych (5.1.2)
reline (>= 0.6.0)
psych (5.3.1)
date
stringio
puma (6.4.3)
puma (6.6.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.7)
rack (3.2.6)
rack-proxy (0.7.7)
rack
rack-session (2.0.0)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.1.0)
rackup (2.3.1)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 7.2.1)
railties (= 8.1.3)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.2.1)
rake (13.4.2)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rdoc (6.7.0)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
redcarpet (3.6.0)
reline (0.5.10)
tsort
redcarpet (3.6.1)
reline (0.6.3)
io-console (~> 0.5)
rspec (3.13.0)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.1)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.0.1)
rspec-rails (7.0.2)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
@ -260,9 +281,9 @@ GEM
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.1)
securerandom (0.3.1)
semantic_range (3.0.0)
rspec-support (3.13.7)
securerandom (0.4.1)
semantic_range (3.1.1)
shakapacker (8.0.2)
activesupport (>= 5.2)
package_json
@ -271,27 +292,28 @@ GEM
semantic_range (>= 2.3.0)
shellany (0.0.1)
signed_multiset (0.2.1)
sqlite3 (2.1.0)
sqlite3 (2.1.1)
mini_portile2 (~> 2.8.0)
stringio (3.1.1)
thor (1.3.2)
thread_safe (0.3.6)
timeout (0.4.1)
stringio (3.2.0)
thor (1.5.0)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo-data (1.2026.1)
tzinfo (>= 1.0.0)
unitwise (2.3.0)
liner (~> 0.2)
memoizable (~> 0.4)
parslet (~> 2.0)
signed_multiset (~> 0.2)
useragent (0.16.10)
webrick (1.8.2)
websocket-driver (0.7.6)
uri (1.1.1)
useragent (0.16.11)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.6.18)
zeitwerk (2.7.5)
PLATFORMS
ruby
@ -309,7 +331,7 @@ DEPENDENCIES
oj (~> 3.16.6)
pg (~> 1.5.8)
puma (~> 6.4)
rails (= 7.2.1)
rails (= 8.1.3)
rails-controller-testing
redcarpet (~> 3.6.0)
rspec-rails (~> 7.0.1)
@ -319,4 +341,4 @@ DEPENDENCIES
unitwise (~> 2.3.0)
BUNDLED WITH
2.5.20
4.0.10

View File

@ -48,7 +48,7 @@ class LogsController < ApplicationController
@log.source_recipe = @recipe
if @log.save
render json: { success: true }
render json: { id: @log.id }
else
render json: @log.errors, status: :unprocessable_entity
end

View File

@ -48,7 +48,7 @@ class RecipesController < ApplicationController
@recipe.user = current_user
if @recipe.save
render json: { success: true }
render json: { id: @recipe.id }
else
render json: @recipe.errors, status: :unprocessable_entity
end

View File

@ -42,7 +42,7 @@ class TaskItemsController < ApplicationController
new_status = !params[:invert].present?
TaskItem.transaction do
@task_items = @task_list.task_items.find(ids)
@task_items.each { |i| i.update_attribute(:completed, new_status) }
@task_items.each { |i| i.update_column(:completed, new_status) }
end
TaskChannel.update_task_list(@task_list)

View File

@ -8,32 +8,27 @@
<script setup>
import { ref } from "vue";
import { ref, watch } from "vue";
import debounce from "lodash/debounce";
const emit = defineEmits(["update:modelValue"]);
const props = defineProps({
placeholder: {
required: false,
type: String,
default: ""
},
modelValue: {
required: false,
type: String,
default: ""
}
});
const text = ref(null);
const model = defineModel({ type: String, default: null });
const text = ref(model.value);
watch(model, (val) => { text.value = val; });
const triggerInput = debounce(function() {
emit("update:modelValue", text.value);
model.value = text.value;
},
250,
{ leading: false, trailing: true })
{ leading: false, trailing: true });
function userUpdateText(newText) {
if (text.value !== newText) {
@ -42,10 +37,4 @@ function userUpdateText(newText) {
}
}
function propUpdateText(newText) {
if (text.value === null && text.value !== newText) {
text.value = newText;
}
}
</script>

View File

@ -87,10 +87,10 @@
return api.getSearchIngredients(text);
}
function searchItemSelected(ingredient) {
ingredient.value = ingredient || null;
ingredient_name.value = ingredient.name || null;
density.value = ingredient.density || null;
function searchItemSelected(selectedIngredient) {
ingredient.value = selectedIngredient || null;
ingredient_name.value = selectedIngredient.name || null;
density.value = selectedIngredient.density || null;
}
function getErrors(type) {

View File

@ -4,7 +4,7 @@
<food-edit :food="food" :validation-errors="validationErrors" action="Creating"></food-edit>
<button type="button" class="button is-primary" @click="save">Save</button>
<router-link class="button is-secondary" to="/food">Cancel</router-link>
<router-link class="button is-secondary" to="/foods">Cancel</router-link>
</div>
</template>

View File

@ -8,11 +8,6 @@
</div>
</log-edit>
<div class="buttons">
<button type="button" class="button is-primary" @click="save">Save Log</button>
<router-link class="button is-secondary" to="/">Cancel</router-link>
</div>
</div>
</template>
@ -51,7 +46,7 @@
loadResource(
api.postLog(log)
.then(() => router.push('/'))
.then(data => router.push({ name: 'log', params: { id: data.id } }))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}

View File

@ -8,11 +8,6 @@
</div>
</log-edit>
<div class="buttons">
<button type="button" class="button is-primary" @click="save">Save Log</button>
<router-link class="button is-secondary" to="/">Cancel</router-link>
</div>
</div>
</template>
@ -45,7 +40,7 @@
validationErrors.value = {};
loadResource(
api.patchLog(log.value)
.then(() => router.push('/'))
.then(() => router.push({ name: 'log', params: { id: log.value.id } }))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}

View File

@ -42,7 +42,7 @@
validationErrors.value = {};
loadResource(
api.postRecipe(recipe.value)
.then(() => router.push('/'))
.then(data => router.push({ name: 'recipe', params: { id: data.id } }))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => validationErrors.value = err.validationErrors()))
);
}

View File

@ -25,10 +25,10 @@
</tr>
<tr>
<td>
<app-search-text placeholder="search names" :value="search.name" @update:modelValue="setSearchName($event)"></app-search-text>
<app-search-text placeholder="search names" :model-value="search.name" @update:modelValue="setSearchName($event)"></app-search-text>
</td>
<td>
<app-search-text placeholder="search tags" :value="search.tags" @update:modelValue="setSearchTags($event)"></app-search-text>
<app-search-text placeholder="search tags" :model-value="search.tags" @update:modelValue="setSearchTags($event)"></app-search-text>
</td>
<td colspan="5"></td>
</tr>

View File

@ -9,7 +9,7 @@ class User < ApplicationRecord
validates :username, presence: true, uniqueness: { case_sensitive: false }
def self.authenticate(username, password)
find_by_username(username).try(:authenticate, password)
find_by(username: username)&.authenticate(password)
end
def display_name

View File

@ -24,7 +24,7 @@ require_relative '../lib/unit_conversion'
module Parsley
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.2
config.load_defaults 8.0
config.autoload_lib(ignore: %w(assets tasks unit_conversion unit_conversion.rb))

View File

@ -1,10 +1,8 @@
Rails.application.configure do
# Verifies that versions and hashed value of the package contents in the project's package.json
config.webpacker.check_yarn_integrity = false
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.cache_classes = true
config.enable_reloading = false
# Eager load code on boot. This eager loads most of Rails and
# your application in memory, allowing both threaded web servers
@ -68,8 +66,8 @@ Rails.application.configure do
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Send deprecation notices to registered listeners.
config.active_support.deprecation = :notify
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Use default logging formatter so that PID and timestamp are not suppressed.
config.log_formatter = ::Logger::Formatter.new

View File

@ -2,7 +2,7 @@ Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.cache_classes = true
config.enable_reloading = false
# Eager load code on boot. This eager loads most of Rails and
# your application in memory, allowing both threaded web servers
@ -56,8 +56,8 @@ Rails.application.configure do
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Send deprecation notices to registered listeners.
config.active_support.deprecation = :notify
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Use default logging formatter so that PID and timestamp are not suppressed.
config.log_formatter = ::Logger::Formatter.new

View File

@ -10,107 +10,107 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2018_09_15_134841) do
ActiveRecord::Schema[8.1].define(version: 2018_09_15_134841) do
create_table "food_units", force: :cascade do |t|
t.integer "food_id", null: false
t.string "name", null: false
t.decimal "gram_weight", precision: 10, scale: 2, null: false
t.string "name", null: false
t.index ["food_id"], name: "index_food_units_on_food_id"
end
create_table "foods", force: :cascade do |t|
t.string "name"
t.string "density"
t.text "notes"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.string "ndbn", limit: 25
t.decimal "water", precision: 10, scale: 2
t.decimal "protein", precision: 10, scale: 2
t.decimal "lipids", precision: 10, scale: 2
t.decimal "ash", precision: 10, scale: 2
t.decimal "carbohydrates", precision: 10, scale: 2
t.integer "kcal"
t.decimal "fiber", precision: 10, scale: 1
t.decimal "sugar", precision: 10, scale: 2
t.integer "user_id"
t.integer "calcium"
t.decimal "carbohydrates", precision: 10, scale: 2
t.decimal "cholesterol", precision: 10, scale: 3
t.decimal "copper", precision: 10, scale: 3
t.datetime "created_at", precision: nil, null: false
t.string "density"
t.decimal "fiber", precision: 10, scale: 1
t.decimal "iron", precision: 10, scale: 2
t.integer "kcal"
t.decimal "lipids", precision: 10, scale: 2
t.integer "magnesium"
t.decimal "manganese", precision: 10, scale: 3
t.string "name"
t.string "ndbn", limit: 25
t.text "notes"
t.integer "phosphorus"
t.integer "potassium"
t.decimal "protein", precision: 10, scale: 2
t.integer "sodium"
t.decimal "zinc", precision: 10, scale: 2
t.decimal "copper", precision: 10, scale: 3
t.decimal "manganese", precision: 10, scale: 3
t.decimal "vit_c", precision: 10, scale: 1
t.decimal "vit_b6", precision: 10, scale: 3
t.decimal "vit_b12", precision: 10, scale: 2
t.decimal "sugar", precision: 10, scale: 2
t.datetime "updated_at", precision: nil, null: false
t.integer "user_id"
t.integer "vit_a"
t.decimal "vit_e", precision: 10, scale: 2
t.decimal "vit_b12", precision: 10, scale: 2
t.decimal "vit_b6", precision: 10, scale: 3
t.decimal "vit_c", precision: 10, scale: 1
t.decimal "vit_d", precision: 10, scale: 1
t.decimal "vit_e", precision: 10, scale: 2
t.decimal "vit_k", precision: 10, scale: 1
t.decimal "cholesterol", precision: 10, scale: 3
t.decimal "water", precision: 10, scale: 2
t.decimal "zinc", precision: 10, scale: 2
t.index ["ndbn"], name: "index_foods_on_ndbn"
end
create_table "logs", force: :cascade do |t|
t.integer "user_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "date", precision: nil
t.text "notes"
t.integer "rating"
t.integer "recipe_id"
t.integer "source_recipe_id"
t.datetime "date", precision: nil
t.integer "rating"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.text "notes"
t.integer "user_id"
end
create_table "notes", force: :cascade do |t|
t.integer "user_id", null: false
t.text "content", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.integer "user_id", null: false
t.index ["user_id"], name: "index_notes_on_user_id"
end
create_table "recipe_ingredients", force: :cascade do |t|
t.integer "food_id"
t.integer "recipe_id"
t.string "name"
t.integer "sort_order"
t.string "quantity"
t.string "units"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.integer "food_id"
t.string "name"
t.text "preparation"
t.string "quantity"
t.integer "recipe_as_ingredient_id"
t.integer "recipe_id"
t.integer "sort_order"
t.string "units"
t.datetime "updated_at", precision: nil, null: false
t.index ["recipe_id"], name: "index_recipe_ingredients_on_recipe_id"
end
create_table "recipe_steps", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "recipe_id"
t.integer "sort_order"
t.text "step"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["recipe_id"], name: "index_recipe_steps_on_recipe_id"
end
create_table "recipes", force: :cascade do |t|
t.string "name"
t.text "description"
t.text "source"
t.string "yields"
t.integer "total_time"
t.integer "active_time"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.boolean "deleted"
t.integer "user_id"
t.boolean "is_log"
t.float "rating"
t.text "step_text"
t.text "description"
t.boolean "is_ingredient"
t.boolean "is_log"
t.string "name"
t.float "rating"
t.text "source"
t.text "step_text"
t.integer "total_time"
t.datetime "updated_at", precision: nil, null: false
t.integer "user_id"
t.string "yields"
end
create_table "recipes_tags", id: false, force: :cascade do |t|
@ -121,94 +121,94 @@ ActiveRecord::Schema[7.2].define(version: 2018_09_15_134841) do
end
create_table "tags", force: :cascade do |t|
t.string "name"
t.string "lowercase_name"
t.datetime "created_at", precision: nil, null: false
t.string "lowercase_name"
t.string "name"
t.datetime "updated_at", precision: nil, null: false
t.index ["lowercase_name"], name: "index_tags_on_lowercase_name", unique: true
end
create_table "task_items", force: :cascade do |t|
t.integer "task_list_id", null: false
t.boolean "completed"
t.datetime "created_at", precision: nil, null: false
t.string "name"
t.string "quantity"
t.datetime "created_at", precision: nil, null: false
t.integer "task_list_id", null: false
t.datetime "updated_at", precision: nil, null: false
t.boolean "completed"
t.index ["task_list_id"], name: "index_task_items_on_task_list_id"
end
create_table "task_lists", force: :cascade do |t|
t.integer "user_id", null: false
t.string "name"
t.datetime "created_at", precision: nil, null: false
t.string "name"
t.datetime "updated_at", precision: nil, null: false
t.integer "user_id", null: false
t.index ["user_id"], name: "index_task_lists_on_user_id"
end
create_table "usda_food_weights", force: :cascade do |t|
t.integer "usda_food_id", null: false
t.decimal "amount", precision: 7, scale: 3
t.datetime "created_at", precision: nil, null: false
t.string "description"
t.decimal "gram_weight", precision: 7, scale: 1
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.integer "usda_food_id", null: false
t.index ["usda_food_id"], name: "index_usda_food_weights_on_usda_food_id"
end
create_table "usda_foods", force: :cascade do |t|
t.string "ndbn", limit: 25
t.string "long_description"
t.string "short_description"
t.decimal "water", precision: 10, scale: 2
t.integer "kcal"
t.decimal "protein", precision: 10, scale: 2
t.decimal "lipid", precision: 10, scale: 2
t.decimal "ash", precision: 10, scale: 2
t.integer "calcium"
t.decimal "carbohydrates", precision: 10, scale: 2
t.decimal "cholesterol", precision: 10, scale: 3
t.decimal "copper", precision: 10, scale: 3
t.datetime "created_at", precision: nil, null: false
t.decimal "fiber", precision: 10, scale: 1
t.decimal "sugar", precision: 10, scale: 2
t.decimal "gram_weight_1", precision: 9, scale: 2
t.decimal "gram_weight_2", precision: 9, scale: 2
t.string "gram_weight_desc_1"
t.string "gram_weight_desc_2"
t.text "ingredients"
t.decimal "iron", precision: 10, scale: 2
t.integer "kcal"
t.decimal "lipid", precision: 10, scale: 2
t.string "long_description"
t.integer "magnesium"
t.decimal "manganese", precision: 10, scale: 3
t.string "manufacturer"
t.string "ndbn", limit: 25
t.string "nutrient_unit"
t.integer "phosphorus"
t.integer "potassium"
t.decimal "protein", precision: 10, scale: 2
t.string "refuse_description"
t.integer "refuse_percent"
t.string "scientific_name"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.integer "calcium"
t.decimal "iron", precision: 10, scale: 2
t.integer "magnesium"
t.integer "phosphorus"
t.integer "potassium"
t.string "short_description"
t.integer "sodium"
t.decimal "zinc", precision: 10, scale: 2
t.decimal "copper", precision: 10, scale: 3
t.decimal "manganese", precision: 10, scale: 3
t.decimal "vit_c", precision: 10, scale: 1
t.decimal "vit_b6", precision: 10, scale: 3
t.decimal "vit_b12", precision: 10, scale: 2
t.integer "vit_a"
t.decimal "vit_e", precision: 10, scale: 2
t.decimal "vit_d", precision: 10, scale: 1
t.decimal "vit_k", precision: 10, scale: 1
t.decimal "cholesterol", precision: 10, scale: 3
t.string "source"
t.string "manufacturer"
t.text "ingredients"
t.string "nutrient_unit"
t.decimal "sugar", precision: 10, scale: 2
t.datetime "updated_at", precision: nil, null: false
t.integer "vit_a"
t.decimal "vit_b12", precision: 10, scale: 2
t.decimal "vit_b6", precision: 10, scale: 3
t.decimal "vit_c", precision: 10, scale: 1
t.decimal "vit_d", precision: 10, scale: 1
t.decimal "vit_e", precision: 10, scale: 2
t.decimal "vit_k", precision: 10, scale: 1
t.decimal "water", precision: 10, scale: 2
t.decimal "zinc", precision: 10, scale: 2
t.index ["long_description"], name: "index_usda_foods_on_long_description"
t.index ["ndbn"], name: "index_usda_foods_on_ndbn"
end
create_table "users", force: :cascade do |t|
t.string "username"
t.boolean "admin"
t.datetime "created_at", precision: nil, null: false
t.string "email"
t.string "full_name"
t.string "password_digest"
t.boolean "admin"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.string "username"
end
end

View File

@ -378,49 +378,62 @@ class UsdaImporter
end
build_enumerator(opened_files).each_slice(500) do |slice|
UsdaFood.transaction do
slice.each do |data|
now = Time.current
food_attrs = []
weight_groups = []
food = UsdaFood.new
slice.each do |data|
food = UsdaFood.new
weight_hashes = []
data.each do |name, rows|
file_info = FILES[name]
obj = food
rows.each do |row|
if file_info[:map_into]
obj = food.send(file_info[:map_into]).build
end
if file_info[:static]
file_info[:static].each do |k, v|
obj.send("#{k}=", v)
end
end
data.each do |name, rows|
file_info = FILES[name]
rows.each do |row|
if file_info[:map_into]
w = {}
file_info[:static]&.each { |k, v| w[k.to_s] = v }
file_info[:map].each { |db, col| w[db.to_s] = row[col] }
weight_hashes << w
else
file_info[:static]&.each { |k, v| food.send("#{k}=", v) }
if file_info[:map_function]
file_info[:map_function].call(obj, row)
file_info[:map_function].call(food, row)
else
file_info[:map].each do |db, col|
obj.send("#{db}=", row[col])
end
file_info[:map].each { |db, col| food.send("#{db}=", row[col]) }
end
end
end
end
food.save!
attrs = food.attributes.except('id')
attrs['created_at'] = now
attrs['updated_at'] = now
food_attrs << attrs
weight_groups << weight_hashes
end
result = UsdaFood.insert_all!(food_attrs, returning: %w[id ndbn])
id_idx = result.columns.index('id')
ndbn_idx = result.columns.index('ndbn')
ndbn_to_id = result.rows.each_with_object({}) { |row, h| h[row[ndbn_idx]] = row[id_idx] }
all_weights = []
food_attrs.each_with_index do |fa, i|
food_id = ndbn_to_id[fa['ndbn']]
weight_groups[i].each do |w|
all_weights << w.merge('usda_food_id' => food_id, 'created_at' => now, 'updated_at' => now)
end
end
UsdaFoodWeight.insert_all!(all_weights) if all_weights.any?
end
ensure
opened_files.each { |k, v| v.close }
sorted_files.each { |k, v| `rm #{v}` }
end
Food.where('ndbn != ?', '').where('ndbn IS NOT NULL').each do |i|
Food.where('ndbn != ?', '').where('ndbn IS NOT NULL').find_each do |i|
i.set_usda_food(i.usda_food)
i.save!
end
@ -444,7 +457,7 @@ class UsdaImporter
loop do
break if enumerate_data.values.all? { |d| d[:done] }
current_ndbn = enumerate_data.select { |_, d| !d[:done] }.values.map { |d| d[:next_ndbn] }.min
current_ndbn = enumerate_data.select { |_, d| !d[:done] }.values.min_by { |d| d[:next_ndbn].to_i }[:next_ndbn]
results = Hash.new { |hash, key| hash[key] = [] }
enumerate_data.each do |name, data|

View File

@ -3,19 +3,45 @@ require 'usda_importer'
RSpec.describe UsdaImporter do
it 'imports' do
i = UsdaImporter.new(Rails.root.join('spec', 'test_data'))
i.import
subject(:import) { UsdaImporter.new(Rails.root.join('spec', 'test_data')).import }
it 'imports the correct number of foods with weights' do
import
expect(UsdaFood.count).to eq 5
butter = UsdaFood.where(ndbn: '01001').first
butter = UsdaFood.find_by(ndbn: '01001')
expect(butter).not_to be_nil
expect(butter.usda_food_weights.count).to eq 4
clif_bar = UsdaFood.where(ndbn: '45042066').first
clif_bar = UsdaFood.find_by(ndbn: '45042066')
expect(clif_bar).not_to be_nil
expect(clif_bar.usda_food_weights.count).to eq 1
end
it 'imports SR28 nutrition fields correctly' do
import
butter = UsdaFood.find_by(ndbn: '01001')
expect(butter.kcal).to eq 717
expect(butter.protein).to eq 0.85
expect(butter.source).to eq 'sr'
end
it 'imports branded nutrition fields via map_function' do
import
clif_bar = UsdaFood.find_by(ndbn: '45042066')
expect(clif_bar.kcal).to eq 368
expect(clif_bar.protein).to eq 13.24
expect(clif_bar.source).to eq 'bf'
end
it 'updates linked Food records with usda nutrition data' do
food = create(:food, ndbn: '01001')
import
food.reload
expect(food.kcal).to eq 717
end
end