From 310d7245a3bcf2891bb22655b58b77bc2c4ff9ce Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 17 Oct 2026 13:11:48 +1300 Subject: [PATCH] Implement nursery sowing functionality: add SowSeedsModal component, enhance planting logic to track seed usage, and update nursery views to display sown and planned seedlings. --- app/assets/stylesheets/_garden_cards.scss | 32 +++ app/controllers/plantings_controller.rb | 16 +- app/helpers/gardens_helper.rb | 53 ++++- app/javascript/components/NurseryPlanter.jsx | 18 +- app/javascript/components/SowSeedsModal.jsx | 212 ++++++++++++++++++ app/models/planting.rb | 12 + app/views/gardens/_nursery_panel.html.haml | 39 +++- app/views/plantings/_facts.html.haml | 3 + ...0000_add_seed_plant_before_to_plantings.rb | 10 + db/schema.rb | 3 +- spec/controllers/plantings_controller_spec.rb | 26 +++ spec/features/gardens/nursery_spec.rb | 69 +++++- spec/models/planting_spec.rb | 36 +++ 13 files changed, 498 insertions(+), 31 deletions(-) create mode 100644 app/javascript/components/SowSeedsModal.jsx create mode 100644 db/migrate/20261017000000_add_seed_plant_before_to_plantings.rb diff --git a/app/assets/stylesheets/_garden_cards.scss b/app/assets/stylesheets/_garden_cards.scss index ae131901e..38ccbb943 100644 --- a/app/assets/stylesheets/_garden_cards.scss +++ b/app/assets/stylesheets/_garden_cards.scss @@ -381,7 +381,39 @@ $garden-card-line: #e6e6e6; } } + .nursery-day + .nursery-day { + margin-top: 1rem; + } + + .nursery-day-heading { + margin: 0 0 0.5rem; + color: $garden-card-muted; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + .nursery-planned .chip { + opacity: 0.8; + } + .nursery-seedling-details { color: $garden-card-muted; } } + +// A seed packet in the sow dialog's list: its photo, if it has one, beside what it is. +.seed-packet { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.seed-packet-photo { + flex: none; + width: 56px; + height: 56px; + border-radius: 2px; + object-fit: cover; +} diff --git a/app/controllers/plantings_controller.rb b/app/controllers/plantings_controller.rb index dcb77bea3..46e83d2f4 100644 --- a/app/controllers/plantings_controller.rb +++ b/app/controllers/plantings_controller.rb @@ -78,7 +78,7 @@ class PlantingsController < DataController @planting.crop = @planting.parent_seed.crop if @planting.parent_seed.present? # Only into your own gardens. A missing garden is left to the validation. authorize! :update, @planting.garden if @planting.garden.present? - @planting.save + use_up_seeds if @planting.save && params[:use_seeds].present? return render_card_json(status: :created) if request.format.json? respond_with @planting @@ -107,13 +107,11 @@ class PlantingsController < DataController authorize! :update, new_garden moving = transplant_quantity - if moving == :invalid - return redirect_to @planting, alert: t('messages.transplant_error', errors: t('messages.transplant_quantity')) - end + return redirect_to @planting, alert: t('messages.transplant_error', errors: t('messages.transplant_quantity')) if moving == :invalid new_planting = build_transplant(new_garden, moving) saved = Planting.transaction do - new_planting.save && finish_or_reduce(moving) || raise(ActiveRecord::Rollback) + (new_planting.save && finish_or_reduce(moving)) || raise(ActiveRecord::Rollback) end if saved @@ -125,6 +123,14 @@ class PlantingsController < DataController private + # Sowing from a packet takes the seeds sown out of it, if it says how many it has. + def use_up_seeds + seed = @planting.parent_seed + return unless seed&.owner == current_member && seed.quantity && @planting.quantity + + seed.update(quantity: [seed.quantity - @planting.quantity, 0].max) + end + # How many are being moved: nil for all of them, or a number short of the # planting's quantity. :invalid if the member asked for none or too many. def transplant_quantity diff --git a/app/helpers/gardens_helper.rb b/app/helpers/gardens_helper.rb index f76c077dc..68db096ed 100644 --- a/app/helpers/gardens_helper.rb +++ b/app/helpers/gardens_helper.rb @@ -15,22 +15,61 @@ module GardensHelper } end - # The seedlings currently in a nursery, newest sown first. + # The plantings currently in a nursery, newest sown first. def nursery_seedlings(nursery) - return Planting.none unless nursery + return [] unless nursery - nursery.plantings.current.includes(crop: :parent).order(planted_at: :desc, id: :desc) + nursery.plantings.current.includes(crop: :parent).order(planted_at: :desc, id: :desc).to_a end - # "12 seedlings", counting each plant, or that there are none. + # What has been sown, as [day, plantings] pairs, newest day first. + def nursery_sown_groups(seedlings) + seedlings.reject(&:planned?).group_by { |seedling| seedling.planted_at || seedling.created_at.to_date } + .sort_by(&:first).reverse + end + + # What is planned but not sown yet, soonest first. + def nursery_planned(seedlings) + seedlings.select(&:planned?).sort_by(&:planted_at) + end + + # "Today", or "Mon 12 Oct · 5 days ago". + def nursery_day_label(day) + today = Time.zone.today + return 'Today' if day == today + + ago = (today - day).to_i + "#{l(day, format: '%a %-d %b')} · #{ago == 1 ? 'yesterday' : "#{ago} days ago"}" + end + + # "12 seedlings", counting each plant, or that there are none sown yet. def nursery_seedling_count(seedlings) - return 'No seedlings yet' if seedlings.empty? + sown = seedlings.reject(&:planned?) + return 'No seedlings yet' if sown.empty? - pluralize(seedlings.sum { |seedling| seedling.quantity || 1 }, 'seedling') + pluralize(sown.sum { |seedling| seedling.quantity || 1 }, 'seedling') end + # The sow seeds dialog's props: the nursery, the member's other gardens to sow + # straight into, and their seed packets to sow from. def nursery_planter_props(nursery) - { garden: { id: nursery.id, name: nursery.name }, label: 'Sow seeds', buttonClass: 'btn btn-primary' } + owner = nursery.owner + { + garden: { id: nursery.id, name: nursery.name }, + gardens: owner.gardens.active.not_nurseries.order_by_name.map { |garden| { id: garden.id, name: garden.name } }, + seeds: owner.seeds.active.includes(:crop, :photos).order(created_at: :desc).limit(200).map { |seed| seed_packet_props(seed) }, + label: 'Sow seeds', + buttonClass: 'btn btn-primary' + } + end + + # A seed packet for the sow dialog, with the photo of it if there is one, as + # that is what tells two packets of the same crop apart. + def seed_packet_props(seed) + photo = seed.photos.first + { id: seed.id, crop_id: seed.crop_id, name: seed.crop_name, quantity: seed.quantity, + heirloom: seed.heirloom, saved: l(seed.created_at.to_date, format: '%-d %b %Y'), + plant_before: seed.plant_before&.iso8601, photo_url: (thumbnail_url(photo) if photo) } end # "2022–2024 · 12 plantings": when an inactive garden was in use, by its plantings. diff --git a/app/javascript/components/NurseryPlanter.jsx b/app/javascript/components/NurseryPlanter.jsx index 96d173e28..8c21e78e7 100644 --- a/app/javascript/components/NurseryPlanter.jsx +++ b/app/javascript/components/NurseryPlanter.jsx @@ -1,17 +1,25 @@ import React, {useState} from 'react'; import PickerButton from './PickerButton'; -import PlantSomethingModal from './PlantSomethingModal'; +import SowSeedsModal from './SowSeedsModal'; -// A button that opens the plant-something dialog for the member's nursery, for -// the panel on their gardens page. Once planted the page reloads to show it. -export default function NurseryPlanter({garden, label, buttonClass}) { +// A button that opens the sow-seeds dialog for the member's nursery, on their +// gardens page. Once sown the page reloads to show it. +export default function NurseryPlanter({garden, gardens, seeds, label, buttonClass}) { const [open, setOpen] = useState(false); return ( <> setOpen(true)} /> - {open && setOpen(false)} onCreated={() => window.location.reload()} />} + {open && ( + setOpen(false)} + onSown={() => window.location.reload()} + /> + )} ); } diff --git a/app/javascript/components/SowSeedsModal.jsx b/app/javascript/components/SowSeedsModal.jsx new file mode 100644 index 000000000..b6141974b --- /dev/null +++ b/app/javascript/components/SowSeedsModal.jsx @@ -0,0 +1,212 @@ +import React, {useState} from 'react'; + +import {postJson} from '../api'; +import {daysBefore, localToday} from '../dates'; +import CropPicker from './CropPicker'; +import Modal from './Modal'; +import WhenPicker from './WhenPicker'; + +const SOURCES = [['mine', 'From my seeds'], ['new', 'New seeds'], ['seedlings', 'Seedlings I got']]; + +// A row of radio pills, the same look as the "when" choice. +function Choices({id, legend, choices, value, onChange}) { + return ( +
+ {legend} +
+ {choices.map(([choice, label]) => ( + + onChange(choice)} + /> + + + ))} +
+
+ ); +} + +function errorMessages(status, data) { + if (status === 422 && data && data.errors) { + const messages = Object.entries(data.errors).flatMap(([field, list]) => list.map((m) => `${field.replace(/_/g, ' ')} ${m}`)); + if (messages.length > 0) return messages; + } + if (status === 403) return ['You can\'t sow into that garden.']; + if (status === 401) return ['Please sign in again to sow seeds.']; + return ['Something went wrong saving that. Please try again.']; +} + +// "Sow seeds": into the nursery, or straight into a garden, from a packet of +// your own seeds, new seeds, or seedlings you were given. A date in the future +// saves a plan, which the nursery lists as "to sow". Sowing from a packet takes +// the seeds out of it. +export default function SowSeedsModal({nursery, gardens, seeds, onClose, onSown}) { + const [when, setWhen] = useState('today'); + const [customDate, setCustomDate] = useState(''); + const [where, setWhere] = useState('nursery'); + const [gardenId, setGardenId] = useState(gardens.length > 0 ? String(gardens[0].id) : ''); + const [source, setSource] = useState(seeds.length > 0 ? 'mine' : 'new'); + const [search, setSearch] = useState(''); + const [seed, setSeed] = useState(null); + const [crop, setCrop] = useState(null); + const [quantity, setQuantity] = useState(6); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState([]); + const [sown, setSown] = useState(null); + + const today = localToday(); + const sownOn = {today, yesterday: daysBefore(today, 1), other: customDate}[when]; + const garden = where === 'nursery' ? nursery : gardens.find((g) => String(g.id) === gardenId); + const cropId = source === 'mine' ? seed && seed.crop_id : crop && crop.id; + const cropName = source === 'mine' ? seed && seed.name : crop && crop.name; + const matching = seeds.filter((packet) => packet.name.toLowerCase().includes(search.trim().toLowerCase())); + + function chooseSource(choice) { + setSource(choice); + setSeed(null); + setCrop(null); + } + + async function save(addAnother) { + setSaving(true); + setErrors([]); + try { + const planting = { + garden_id: garden.id, + crop_id: cropId, + quantity, + planted_from: source === 'seedlings' ? 'seedling' : 'seed', + ...(source === 'mine' ? {parent_seed_id: seed.id} : {}), + ...(when === 'today' ? {} : {planted_at: sownOn}), + }; + const {ok, status, data} = await postJson('/plantings.json', {planting, ...(source === 'mine' ? {use_seeds: 1} : {})}); + if (!ok) { + setErrors(errorMessages(status, data)); + } else if (addAnother) { + setSown(`Sowed ${quantity} ${cropName}.`); + setSeed(null); + setCrop(null); + } else { + onSown(); + return; + } + } catch (error) { + setErrors(['Couldn\'t reach the server. Please try again.']); + } + setSaving(false); + } + + const ready = Boolean(cropId) && Boolean(garden) && quantity > 0 && (when !== 'other' || customDate); + const target = where === 'nursery' ? 'the nursery' : (garden ? garden.name : 'a garden'); + + return ( + +
+ {errors.length > 0 && ( +
+ That didn’t work. +
    {errors.map((message) =>
  • {message}
  • )}
+
+ )} + {sown &&
{sown} Sow another below.
} + { setWhen(choice); if (choice === 'other' && !customDate) setCustomDate(today); }} + date={customDate} + onDate={(event) => setCustomDate(event.target.value)} + hint={sownOn > today ? 'A date in the future saves a plan, not a sowing.' : sownOn} + /> + +

+ {where === 'nursery' + ? 'They stay in the nursery as seedlings until you plant them out.' + : 'They go in as a planting, with no nursery stage.'} +

+ {where === 'garden' && ( + gardens.length > 0 ? ( + <> + + + + ) :

You don’t have another garden yet.

+ )} + + {source === 'mine' ? ( +
+ + setSearch(event.target.value)} /> +
+ {matching.map((packet) => ( + + ))} + {matching.length === 0 &&

No seed packets match. Choose “New seeds” to add one.

} +
+
+ ) : ( +
+ {crop ? ( +

+ {crop.name}{' '} + +

+ ) : } +
+ )} + + setQuantity(Math.max(0, parseInt(event.target.value, 10) || 0))} + /> + {source === 'mine' && seed && seed.quantity != null && ( +

That packet goes from {seed.quantity} to {Math.max(0, seed.quantity - quantity)}.

+ )} +
+
+ + + +
+
+ ); +} diff --git a/app/models/planting.rb b/app/models/planting.rb index ba3a1e9fe..39fb00140 100644 --- a/app/models/planting.rb +++ b/app/models/planting.rb @@ -37,6 +37,7 @@ class Planting < ApplicationRecord has_many :plants, dependent: :destroy # Once a planting is on a layout, keep its plants in step with its quantity. + before_create :remember_seed_plant_before after_update :sync_plants, if: -> { saved_change_to_quantity? && plants.exists? } scope :current, -> { where.not(finished: true).where.not(failed: true) } @@ -158,6 +159,11 @@ class Planting < ApplicationRecord quantity.to_i > MAX_PLANTS end + # Sown after the year its seed packet said to plant it by. + def sown_from_expired_seeds? + seed_plant_before.present? && planted_at.present? && planted_at > seed_plant_before + end + def growing? planted? && !finished? end @@ -183,6 +189,12 @@ class Planting < ApplicationRecord private + # Copied from the packet when the planting is made, as the packet may be + # edited or thrown away later. + def remember_seed_plant_before + self.seed_plant_before ||= parent_seed&.plant_before + end + def cannot_be_finished_and_failed errors.add(:failed, :failed_and_finished) if finished && failed end diff --git a/app/views/gardens/_nursery_panel.html.haml b/app/views/gardens/_nursery_panel.html.haml index 8e539540a..f047b71ab 100644 --- a/app/views/gardens/_nursery_panel.html.haml +++ b/app/views/gardens/_nursery_panel.html.haml @@ -1,6 +1,9 @@ -# The member's nursery, above their gardens: where seedlings are raised before -# they're planted out. A member who hasn't chosen one yet is asked about it. -- seedlings = nursery_seedlings(nursery) +:ruby + seedlings = nursery_seedlings(nursery) + sown_groups = nursery_sown_groups(seedlings) + planned = nursery_planned(seedlings) .card.nursery-panel .nursery-panel-header %div @@ -17,14 +20,28 @@ = render 'gardens/what_is_a_nursery' = link_to 'Set up a nursery', nursery_path, class: 'btn btn-primary' - elsif seedlings.empty? - %p.text-muted.mb-0 Sow some seeds and they'll show up here, ready to transplant. + %p.text-muted.mb-0 Sow some seeds and they'll show up here, ready to plant out. - else - %ul.nursery-seedlings - - seedlings.each do |seedling| - %li - = render 'plantings/tiny', planting: seedling - %small.nursery-seedling-details - - if seedling.quantity.present? - × #{seedling.quantity} - - if seedling.planted_at.present? - sown #{l(seedling.planted_at, format: :short)} + - sown_groups.each do |day, group| + %section.nursery-day + %h3.nursery-day-heading= nursery_day_label(day) + %ul.nursery-seedlings + - group.each do |seedling| + %li + = render 'plantings/tiny', planting: seedling + %small.nursery-seedling-details + - if seedling.quantity.present? + × #{seedling.quantity} + - if seedling.planted_from == 'seedling' + · got as seedlings + - if seedling.sown_from_expired_seeds? + · expired seeds + - if planned.any? + %section.nursery-day.nursery-planned + %h3.nursery-day-heading To sow + %ul.nursery-seedlings + - planned.each do |seedling| + %li + = render 'plantings/tiny', planting: seedling + %small.nursery-seedling-details + planned · #{l(seedling.planted_at, format: '%a %-d %b')} diff --git a/app/views/plantings/_facts.html.haml b/app/views/plantings/_facts.html.haml index 2f1846cb3..9dac6defe 100644 --- a/app/views/plantings/_facts.html.haml +++ b/app/views/plantings/_facts.html.haml @@ -90,6 +90,9 @@ .card.fact-card %h3 Parent seed = render 'seeds/thumbnail', seed: planting.parent_seed + - if planting.sown_from_expired_seeds? + %span.text-warning-emphasis + Sown from expired seeds (plant before #{planting.seed_plant_before.year}) - if planting.crop.perennial .card.fact-card diff --git a/db/migrate/20261017000000_add_seed_plant_before_to_plantings.rb b/db/migrate/20261017000000_add_seed_plant_before_to_plantings.rb new file mode 100644 index 000000000..76c877c21 --- /dev/null +++ b/db/migrate/20261017000000_add_seed_plant_before_to_plantings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# The plant-before date of the seed packet a planting was sown from, as it was +# at the time, so we can say it was sown from expired seeds even if the packet +# is later edited or deleted. +class AddSeedPlantBeforeToPlantings < ActiveRecord::Migration[8.1] + def change + add_column :plantings, :seed_plant_before, :date + end +end diff --git a/db/schema.rb b/db/schema.rb index ed43af878..1d5f63d2a 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[8.1].define(version: 2026_10_16_010000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_17_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -927,6 +927,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_16_010000) do t.date "planted_at" t.string "planted_from" t.integer "quantity" + t.date "seed_plant_before" t.string "slug" t.string "sunniness" t.integer "transplanted_from_id" diff --git a/spec/controllers/plantings_controller_spec.rb b/spec/controllers/plantings_controller_spec.rb index dbc385d14..47ab4b477 100644 --- a/spec/controllers/plantings_controller_spec.rb +++ b/spec/controllers/plantings_controller_spec.rb @@ -121,6 +121,32 @@ describe PlantingsController do it { expect(assigns(:planting).owner).to eq subject.current_member } end + + describe "sowing from a seed packet" do + let(:seed) { create(:seed, owner: member, quantity: 120) } + let(:garden) { member.gardens.first } + let(:planting_params) { { garden_id: garden.id, parent_seed_id: seed.id, quantity: 6 } } + + context "when asked to use the seeds up" do + before { post :create, params: { planting: planting_params, use_seeds: '1' } } + + it { expect(seed.reload.quantity).to eq 114 } + end + + context "without asking to use the seeds up" do + before { post :create, params: { planting: planting_params } } + + it { expect(seed.reload.quantity).to eq 120 } + end + + context "when sowing more than the packet has" do + let(:seed) { create(:seed, owner: member, quantity: 4) } + + before { post :create, params: { planting: planting_params, use_seeds: '1' } } + + it { expect(seed.reload.quantity).to eq 0 } + end + end end describe 'GET :edit' do diff --git a/spec/features/gardens/nursery_spec.rb b/spec/features/gardens/nursery_spec.rb index 82cc9d751..d35471fb2 100644 --- a/spec/features/gardens/nursery_spec.rb +++ b/spec/features/gardens/nursery_spec.rb @@ -22,13 +22,23 @@ describe "Nursery" do let(:crop) { create(:crop, name: 'tomato') } before do - create(:planting, garden: member.nursery, owner: member, crop:, quantity: 6) + create(:planting, garden: member.nursery, owner: member, crop:, quantity: 6, planted_at: Time.zone.today) + create(:planting, garden: member.nursery, owner: member, crop: create(:crop, name: 'basil'), + planted_at: 1.week.from_now.to_date) visit member_gardens_path(member) end + it "groups them under the day they were sown" do + within('.nursery-panel') { expect(page).to have_css '.nursery-day-heading', text: /today/i } + end + + it "lists plans to sow later separately" do + within('.nursery-planned') { expect(page).to have_link 'basil' } + end + it "sows in a dialog" do click_button 'Sow seeds' - expect(page).to have_css '.modal', text: 'Plant something in Nursery' + expect(page).to have_css '.modal', text: 'Sow seeds' end it "lists the seedlings above the gardens" do @@ -37,6 +47,61 @@ describe "Nursery" do end end + describe "sowing from a seed packet" do + let(:crop) { create(:crop, name: 'beetroot') } + let!(:packet) { create(:seed, owner: member, crop:, quantity: 120) } + + before do + member.nursery + visit member_gardens_path(member) + click_button 'Sow seeds' + within('.modal') do + click_button 'beetroot' + fill_in 'How many seeds?', with: 6 + click_button 'Sow into the nursery' + end + page.assert_no_selector '.modal' + end + + it "sows them into the nursery" do + expect(member.nursery.plantings.pluck(:crop_id, :quantity, :parent_seed_id)).to eq [[crop.id, 6, packet.id]] + end + + it "takes them out of the packet" do + expect(packet.reload.quantity).to eq 114 + end + end + + describe "recognising a seed packet by its photo" do + let(:packet) { create(:seed, owner: member, crop: create(:crop, name: 'beetroot')) } + + before do + packet.photos << create(:photo, owner: member, thumbnail_url: 'https://example.com/packet.jpg') + member.nursery + visit member_gardens_path(member) + click_button 'Sow seeds' + end + + it "shows the packet's photo in the list" do + expect(page).to have_css "img[alt='Photo of the beetroot packet']" + end + end + + describe "sowing from expired seeds" do + let(:crop) { create(:crop, name: 'beetroot') } + + before do + create(:seed, owner: member, crop:, plant_before_year: 2020) + member.nursery + visit member_gardens_path(member) + click_button 'Sow seeds' + end + + it "marks the packet as expired in the list" do + expect(page).to have_css '.badge', text: 'Expired 2020' + end + end + describe "choosing a nursery" do before do member.gardens.first.update!(name: 'Backyard') diff --git a/spec/models/planting_spec.rb b/spec/models/planting_spec.rb index a1d2854c3..0dd288f72 100644 --- a/spec/models/planting_spec.rb +++ b/spec/models/planting_spec.rb @@ -754,4 +754,40 @@ describe Planting do expect { planting.destroy }.to change(Plant, :count).by(-3) end end + + describe 'sowing from expired seeds' do + let(:seed) { create(:seed, plant_before_year: 2025) } + let(:planting) { create(:planting, parent_seed: seed, planted_at:) } + + context 'when sown after the packet year' do + let(:planted_at) { Date.new(2026, 3, 1) } + + it { expect(planting).to be_sown_from_expired_seeds } + end + + context 'when sown in the packet year' do + let(:planted_at) { Date.new(2025, 6, 1) } + + it { expect(planting).not_to be_sown_from_expired_seeds } + end + + context 'without a parent seed' do + subject(:planting) { create(:planting, planted_at: Date.new(2026, 3, 1)) } + + it { is_expected.not_to be_sown_from_expired_seeds } + end + + context 'when the packet date changes later' do + let(:planted_at) { Date.new(2026, 3, 1) } + + before do + planting + seed.update!(plant_before_year: 2030) + end + + it 'remembers the date at the time' do + expect(planting.reload).to be_sown_from_expired_seeds + end + end + end end