From ea46d88d3c7cb64964015a45b296a8219dcd48e9 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Tue, 3 Nov 2026 09:10:26 +1300 Subject: [PATCH] Enhance crop lifespan and job templates display with new styles and functionality --- app/assets/stylesheets/_crops.scss | 9 +++ app/assets/stylesheets/_jobs.scss | 36 +++++------- app/models/crop.rb | 12 ++++ app/views/crops/_job_templates.html.haml | 11 ++-- app/views/crops/_predictions.html.haml | 45 ++++++++------- spec/models/crop_spec.rb | 52 ++++++++++++++++++ ...rops_controller_show_job_templates_spec.rb | 21 ++++++- .../crops_controller_show_sections_spec.rb | 55 ++++++++++++++++++- 8 files changed, 191 insertions(+), 50 deletions(-) diff --git a/app/assets/stylesheets/_crops.scss b/app/assets/stylesheets/_crops.scss index 9acfd4aaa..e49f93a5c 100644 --- a/app/assets/stylesheets/_crops.scss +++ b/app/assets/stylesheets/_crops.scss @@ -229,6 +229,15 @@ } } +// Annual or perennial is the point of the lifespan card, so it is the biggest thing in it. +.lifespan-word { + display: block; + font-size: 2rem; + font-weight: 700; + line-height: 1.2; + margin-bottom: 0.25rem; +} + // An annual's median lifespan, under the word annual. .crop-median-lifespan { align-items: center; diff --git a/app/assets/stylesheets/_jobs.scss b/app/assets/stylesheets/_jobs.scss index 450d6134d..a620ecfe7 100644 --- a/app/assets/stylesheets/_jobs.scss +++ b/app/assets/stylesheets/_jobs.scss @@ -1107,38 +1107,32 @@ $notepad-trail: #a4c98c; } .crop-notepad { - background: $white; - border: 1px solid rgba(65, 63, 59, 0.15); - border-radius: $card-radius; - box-shadow: 1px 3px 3px 1px #cac1b3; margin-bottom: 1rem; - overflow: hidden; - padding-bottom: 0.5rem; } +// Only shown to tell several templates apart, or to say a template is for part of the +// year, so it is a subheading, not a second bar inside the section's. .crop-notepad-title { align-items: baseline; - background: $green; display: flex; gap: 1rem; justify-content: space-between; - padding: 0.5rem 1.1rem; + margin-bottom: 0.25rem; - // The site makes headings in a section green banners with a shadow; this one - // is plain text on the notepad's own bar. + // The site makes headings in a section green banners with a shadow; this one is plain. h3 { background: none; box-shadow: none; - color: $white; - font-size: 1.3rem; - font-weight: 400; + color: $notepad-leaf; + font-size: 1.1rem; + font-weight: 700; margin: 0; padding: 0; } } .crop-notepad-season { - color: $white; + color: $ink-muted; font-size: 0.85rem; } @@ -1203,13 +1197,13 @@ $notepad-trail: #a4c98c; .crop-job-add { margin: 0.25rem 0 1rem; - // White on the button, as in the heading bars, not the menu's green. - > .menu-icon, - .btn > .menu-icon { - color: inherit; - margin-right: 0.4rem; - vertical-align: text-bottom; - } +} + +// White on the button, as in the heading bars, not the menu's green. +.record-page .crop-job-add .menu-icon { + color: inherit; + margin-right: 0.4rem; + vertical-align: text-bottom; } // The day to plant on, above the button that adds the jobs. diff --git a/app/models/crop.rb b/app/models/crop.rb index d65aa23ef..d137cafa6 100644 --- a/app/models/crop.rb +++ b/app/models/crop.rb @@ -189,6 +189,18 @@ class Crop < ApplicationRecord reason_for_rejection end + # The seed packets of this crop and of its varieties that give a figure for a + # packet fact such as row_spacing. A packet that leaves it blank, or says zero, + # gives none. + def seeds_with(attribute) + Seed.of_crop(self).where.not(attribute => [nil, 0]) + end + + # The median of that figure across those packets, or nil when there are none. + def seed_median(attribute) + seeds_with(attribute).median(attribute) + end + def update_medians plantings.each(&:update_harvest_days!) update_lifespan_medians diff --git a/app/views/crops/_job_templates.html.haml b/app/views/crops/_job_templates.html.haml index 226d099e2..9edea293a 100644 --- a/app/views/crops/_job_templates.html.haml +++ b/app/views/crops/_job_templates.html.haml @@ -6,10 +6,13 @@ .crop-job-templates - templates.each do |template| .crop-notepad - .crop-notepad-title - %h3= template.name - - unless template.season_any? - %span.crop-notepad-season= crop_job_option(:season, template.season) + -# The section's heading already says what this is: a template's own name is + -# only needed to tell several apart, or to say it is for part of the year. + - if templates.size > 1 || !template.season_any? + .crop-notepad-title + %h3= template.name + - unless template.season_any? + %span.crop-notepad-season= crop_job_option(:season, template.season) %ol.crop-notepad-steps - template.steps.each do |step| %li.crop-job-step diff --git a/app/views/crops/_predictions.html.haml b/app/views/crops/_predictions.html.haml index 27f9dc63a..43b7d60b0 100644 --- a/app/views/crops/_predictions.html.haml +++ b/app/views/crops/_predictions.html.haml @@ -11,26 +11,24 @@ .card.fact-card.lifespan-card .card-body.text-center %h3 - = link_to 'https://en.wikipedia.org/wiki/Annual_vs._perennial_plant_evolution', class: 'crop-longevity' do + Lifespan + = editable :select, crop, :perennial, display_field: '.lifespan-display', values: [['Annual', false], ['Perennial', true]] + .lifespan-display + = link_to 'https://en.wikipedia.org/wiki/Annual_vs._perennial_plant_evolution', class: 'lifespan-word' do = crop.perennial? ? 'Perennial' : 'Annual' - - if crop.perennial? - %small living more than two years - - else - %small living and reproducing in a single year or less - - if crop.median_lifespan.present? - - weeks = in_weeks(crop.median_lifespan) - .crop-median-lifespan - %i.far.fa-calendar.fa-2x.amber-text{ 'aria-hidden': 'true' } - - if weeks > 1 - %strong #{weeks} #{'week'.pluralize(weeks)} - %small median lifespan - - else - %small.text-muted We don't have enough data yet on the median lifespan. - - if can? :wrangle, @crop - %small.edit-link - = bootstrap_form_for(@crop) do |f| - = f.hidden_field :perennial, value: crop.annual? - = f.submit :toggle + - if crop.perennial? + %small living more than two years + - else + %small living and reproducing in a single year or less + - if crop.median_lifespan.present? + - weeks = in_weeks(crop.median_lifespan) + .crop-median-lifespan + %i.far.fa-calendar.fa-2x.amber-text{ 'aria-hidden': 'true' } + - if weeks > 1 + %strong #{weeks} #{'week'.pluralize(weeks)} + %small median lifespan + - else + %small.text-muted We don't have enough data yet on the median lifespan. - if crop.median_days_to_first_harvest.present? = render 'layouts/fact_card', @@ -48,9 +46,14 @@ - if crop.spread.present? = render 'layouts/fact_card', title: 'Spread', value: "#{crop.spread}cm", description: nil - - if crop.row_spacing.present? + -# Members' seed packets say what the crop needs, if the crop itself doesn't: their median. + - packets_spacing = crop.seed_median(:row_spacing) + - if packets_spacing || crop.row_spacing.present? + - spacing = packets_spacing || crop.row_spacing + - packets = crop.seeds_with(:row_spacing).count if packets_spacing = render 'layouts/fact_card', - title: 'Row Spacing', value: "#{crop.row_spacing}cm", description: nil + title: 'Row Spacing', value: "#{number_with_precision(spacing, precision: 1, strip_insignificant_zeros: true)}cm", + description: (packets ? "median of #{pluralize(packets, 'seed packet')}" : nil) - if crop.growing_degree_days.present? = render 'layouts/fact_card', title: 'Growing Degree Days', value: crop.growing_degree_days, description: nil diff --git a/spec/models/crop_spec.rb b/spec/models/crop_spec.rb index f2e4039d9..3fe6a75f1 100644 --- a/spec/models/crop_spec.rb +++ b/spec/models/crop_spec.rb @@ -707,4 +707,56 @@ describe Crop do expect(described_class.icon_names).to eq described_class.icon_names.sort end end + + describe 'facts from seed packets' do + subject(:crop) { create(:tomato) } + + let(:variety) { create(:crop, name: 'cherry tomato', parent: crop) } + + describe '#seed_median' do + it { expect(crop.seed_median(:row_spacing)).to be_nil } + + context 'with packets that give a row spacing' do + before do + create(:seed, crop:, row_spacing: 30) + create(:seed, crop:, row_spacing: 60) + create(:seed, crop:, row_spacing: 90) + end + + it { expect(crop.seed_median(:row_spacing)).to eq 60 } + end + + context 'with an even number of packets' do + before do + create(:seed, crop:, row_spacing: 30) + create(:seed, crop:, row_spacing: 50) + end + + it { expect(crop.seed_median(:row_spacing)).to eq 40 } + end + + context 'with a packet that leaves it blank, or says zero' do + before do + create(:seed, crop:, row_spacing: 30) + create(:seed, crop:, row_spacing: nil) + create(:seed, crop:).update_column(:row_spacing, 0) # rubocop:disable Rails/SkipsModelValidations + end + + it { expect(crop.seed_median(:row_spacing)).to eq 30 } + it { expect(crop.seeds_with(:row_spacing).count).to eq 1 } + end + + context 'with a packet of a variety' do + before { create(:seed, crop: variety, row_spacing: 45) } + + it { expect(crop.seed_median(:row_spacing)).to eq 45 } + end + + context 'with a packet of another crop' do + before { create(:seed, crop: create(:crop, name: 'carrot'), row_spacing: 10) } + + it { expect(crop.seed_median(:row_spacing)).to be_nil } + end + end + end end diff --git a/spec/requests/crops_controller_show_job_templates_spec.rb b/spec/requests/crops_controller_show_job_templates_spec.rb index d240c31a9..26c948972 100644 --- a/spec/requests/crops_controller_show_job_templates_spec.rb +++ b/spec/requests/crops_controller_show_job_templates_spec.rb @@ -11,7 +11,7 @@ describe CropsController, '#show job templates' do before { get crop_path(crop) } - it { is_expected.to have_css('.crop-job-templates h3', text: 'Tomato jobs') } + it { is_expected.to have_no_css('.crop-job-templates h3') } it { is_expected.to have_css('.crop-job-step', text: 'Stake or cage the plants') } it { is_expected.to have_css('.crop-job-step', text: '2 weeks after planting out') } @@ -95,6 +95,23 @@ describe CropsController, '#show job templates' do it { is_expected.to have_no_css('.crop-job-add') } end + context 'with two templates' do + before do + create(:crop_job_step, crop_job_template: create(:crop_job_template, crop:, name: 'Early jobs', status: 'published')) + get crop_path(crop) + end + + it { is_expected.to have_css('.crop-job-templates h3', text: 'Early jobs') } + it { is_expected.to have_css('.crop-job-templates h3', text: 'Tomato jobs') } + end + + context 'with a template for part of the year' do + let(:template) { create(:crop_job_template, crop:, name: 'Tomato jobs', status: 'published', season: 'spring_summer') } + + it { is_expected.to have_css('.crop-job-templates h3', text: 'Tomato jobs') } + it { is_expected.to have_css('.crop-notepad-season', text: 'Spring and summer') } + end + context 'with a draft template' do let(:template) { create(:crop_job_template, crop:, name: 'Tomato jobs', status: 'draft') } @@ -106,7 +123,7 @@ describe CropsController, '#show job templates' do before { get crop_path(variety) } - it { is_expected.to have_css('.crop-job-templates h3', text: 'Tomato jobs') } + it { is_expected.to have_no_css('.crop-job-templates h3') } end context 'with a template for another crop' do diff --git a/spec/requests/crops_controller_show_sections_spec.rb b/spec/requests/crops_controller_show_sections_spec.rb index 5c27ee1d1..970e1d4cb 100644 --- a/spec/requests/crops_controller_show_sections_spec.rb +++ b/spec/requests/crops_controller_show_sections_spec.rb @@ -89,7 +89,7 @@ describe CropsController, '#show sections' do context 'with an annual with a median lifespan of 13 weeks' do let(:perennial) { false } - it { is_expected.to have_css('.lifespan-card h3', text: 'Annual') } + it { is_expected.to have_css('.lifespan-card .lifespan-word', text: 'Annual') } it { is_expected.to have_css('.lifespan-card .crop-median-lifespan', text: '13 weeks median lifespan') } it { is_expected.to have_css('.lifespan-card .fa-calendar') } it { is_expected.to have_no_css('.lifespan-card svg') } @@ -113,7 +113,7 @@ describe CropsController, '#show sections' do context 'with a perennial' do let(:perennial) { true } - it { is_expected.to have_css('.lifespan-card h3', text: 'Perennial') } + it { is_expected.to have_css('.lifespan-card .lifespan-word', text: 'Perennial') } it { is_expected.to have_no_css('.crop-median-lifespan') } end end @@ -134,4 +134,55 @@ describe CropsController, '#show sections' do it { is_expected.to have_no_css('section.crop-jobs') } end + + describe 'changing whether the crop is annual or perennial' do + context 'when signed in as a crop wrangler' do + before do + sign_in create(:crop_wrangling_member) + get crop_path(crop) + end + + it { is_expected.to have_css('.lifespan-card .edit-link a.editable') } + it { is_expected.to have_css('.lifespan-card select[name="crop[perennial]"]', visible: :all) } + it { is_expected.to have_no_css('.lifespan-card input[type="submit"][value="toggle"]', visible: :all) } + end + + context 'when signed in as an ordinary member' do + before do + sign_in create(:member) + get crop_path(crop) + end + + it { is_expected.to have_no_css('.lifespan-card .edit-link') } + it { is_expected.to have_no_css('.lifespan-card select', visible: :all) } + end + end + + describe 'the row spacing card' do + context 'with seed packets that give a row spacing' do + before do + create(:seed, crop:, row_spacing: 30) + create(:seed, crop:, row_spacing: 60) + create(:seed, crop:, row_spacing: nil) + get crop_path(crop) + end + + it { is_expected.to have_css('.fact-card', text: 'Row Spacing 45cm median of 2 seed packets') } + end + + context 'with a crop row spacing and no packets that give one' do + let(:crop) { create(:tomato, row_spacing: 25) } + + before { get crop_path(crop) } + + it { is_expected.to have_css('.fact-card', text: 'Row Spacing 25cm') } + it { is_expected.to have_no_css('.fact-card', text: 'median of') } + end + + context 'with neither' do + before { get crop_path(crop) } + + it { is_expected.to have_no_css('.fact-card', text: 'Row Spacing') } + end + end end