From a9849a7e5059371d6fa7b24fd79e749a8353f169 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:45:19 +0000 Subject: [PATCH 01/30] Redirect comments actions to commentable with #comments anchor Update CommentsController create, update, and destroy actions to redirect to the commentable resource path with the #comments anchor tag. Also update CommentsController spec assertions to expect the #comments anchor tag on redirection. Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com> --- app/controllers/comments_controller.rb | 12 +++++++++--- spec/controllers/comments_controller_spec.rb | 6 +++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index ab7e5e8b4..993c9c0f2 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -35,22 +35,28 @@ class CommentsController < ApplicationController @commentable = @comment.commentable @comment.author = current_member @comment.save - respond_with @comment, location: @commentable + respond_with @comment, location: commentable_location(@commentable) end def update @comment.update(body: comment_params['body']) - respond_with @comment, location: @comment.commentable + respond_with @comment, location: commentable_location(@comment.commentable) end def destroy @commentable = @comment.commentable @comment.destroy - respond_with(@commentable) + respond_with(@commentable, location: commentable_location(@commentable)) end private + def commentable_location(commentable) + return nil unless commentable + + polymorphic_path(commentable, anchor: 'comments') + end + def find_commentable return unless params[:comment] diff --git a/spec/controllers/comments_controller_spec.rb b/spec/controllers/comments_controller_spec.rb index 188142af6..68e630470 100644 --- a/spec/controllers/comments_controller_spec.rb +++ b/spec/controllers/comments_controller_spec.rb @@ -112,7 +112,7 @@ describe CommentsController do } end.to change(Comment, :count).by(1) - expect(response).to redirect_to(planting) + expect(response).to redirect_to(planting_path(planting, anchor: 'comments')) expect(Comment.last.commentable).to eq(planting) end end @@ -124,7 +124,7 @@ describe CommentsController do let(:comment) { create(:comment, author: member) } it "redirects to the comment's post" do - expect(response).to redirect_to(comment.commentable) + expect(response).to redirect_to(post_path(comment.commentable, anchor: 'comments')) end end @@ -154,7 +154,7 @@ describe CommentsController do let(:comment) { create(:comment, author: member) } it "redirects to the post the comment was on" do - expect(response).to redirect_to(comment.commentable) + expect(response).to redirect_to(post_path(comment.commentable, anchor: 'comments')) end end From 0d24f5c27db0833b53e56b497cc81e1fb5d85568 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 14:38:27 +1300 Subject: [PATCH 02/30] Drop the byebug (#5052) --- Gemfile | 2 +- Gemfile.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 1825c0484..2a1704f04 100644 --- a/Gemfile +++ b/Gemfile @@ -140,11 +140,11 @@ end group :development, :test do gem 'bullet' # performance tuning by finding unnecesary queries - gem 'byebug' # debugging gem 'capybara' # integration tests gem 'capybara-email' # integration tests for email gem 'capybara-screenshot' # for test debugging gem 'database_cleaner' + gem 'debug', platforms: %i(mri windows), require: 'debug/prelude' # debugging gem 'factory_bot_rails' # for creating test data gem 'faker' gem 'haml-rails' # HTML templating language diff --git a/Gemfile.lock b/Gemfile.lock index af99cc581..53d7d63a4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,8 +129,6 @@ GEM bullet (8.3.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) - byebug (13.0.0) - reline (>= 0.6.0) cancancan (3.7.0) capybara (3.40.0) addressable @@ -177,6 +175,9 @@ GEM database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) date (3.6.0) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) devise (5.0.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -643,7 +644,6 @@ DEPENDENCIES bootstrap_form (>= 4.5.0) bullet bundler (>= 2.4.22) - byebug cancancan capybara capybara-email @@ -655,6 +655,7 @@ DEPENDENCIES csv_shaper dalli database_cleaner + debug devise discard (>= 1.2) dotenv-rails From ad74235b954335c4dc4065cd3e7b70cde35a0e49 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sat, 24 Oct 2026 12:40:18 +1030 Subject: [PATCH 03/30] Make flash messages sticky when page is scrolled (#4999) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/assets/stylesheets/overrides.scss | 11 +++++++++++ app/views/shared/_flash_messages.html.haml | 10 ++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/overrides.scss b/app/assets/stylesheets/overrides.scss index e9541c952..33f1ee89f 100755 --- a/app/assets/stylesheets/overrides.scss +++ b/app/assets/stylesheets/overrides.scss @@ -359,6 +359,17 @@ footer { z-index: $highest-level; } +.flash-messages { + position: sticky; + top: 4rem; + z-index: $zindex-sticky; + margin-bottom: 1rem; + + .alert { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } +} + .alert { a { font-weight: 800; diff --git a/app/views/shared/_flash_messages.html.haml b/app/views/shared/_flash_messages.html.haml index 103c9c799..36752559b 100644 --- a/app/views/shared/_flash_messages.html.haml +++ b/app/views/shared/_flash_messages.html.haml @@ -1,4 +1,6 @@ -- flash.each do |type, content| - %div{ class: build_alert_classes(type), role: "alert" } - %button.btn-close{ type: "button", "aria-label" => "Close", "data-bs-dismiss" => "alert" } - = sanitize(content) +- if flash.any? + .flash-messages + - flash.each do |type, content| + %div{ class: build_alert_classes(type), role: "alert" } + %button.btn-close{ type: "button", "aria-label" => "Close", "data-bs-dismiss" => "alert" } + = sanitize(content) From d62125e4f5a819647ff9e53202ee27d938b6d36c Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 12:42:36 +1300 Subject: [PATCH 04/30] Add world neighbours feature for job suggestions based on latitude --- app/helpers/job_ideas_helper.rb | 4 +- app/services/job_ideas.rb | 5 ++- app/services/job_ideas/nearby.rb | 22 ++++++++-- app/services/job_ideas/world.rb | 32 ++++++++++++++ app/views/job_ideas/_list.html.haml | 2 +- config/locales/en.yml | 12 +++++- docs/adr/0001-world-neighbours.md | 50 ++++++++++++++++++++++ spec/services/job_ideas/nearby_spec.rb | 8 ++++ spec/services/job_ideas/world_spec.rb | 59 ++++++++++++++++++++++++++ 9 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 app/services/job_ideas/world.rb create mode 100644 docs/adr/0001-world-neighbours.md create mode 100644 spec/services/job_ideas/world_spec.rb diff --git a/app/helpers/job_ideas_helper.rb b/app/helpers/job_ideas_helper.rb index a03213829..bec9cd564 100644 --- a/app/helpers/job_ideas_helper.rb +++ b/app/helpers/job_ideas_helper.rb @@ -24,7 +24,7 @@ module JobIdeasHelper # The sections of the ideas, in order: the kinds of idea each holds. JOB_IDEA_SECTIONS = { templates: %i(template), this_time: %i(past_job past_planting), harvest: %i(harvest), - community: %i(community), nearby: %i(nearby) + community: %i(community), nearby: %i(nearby), world: %i(world) }.freeze # The shown ideas under their sections, those with none left out. @@ -38,7 +38,7 @@ module JobIdeasHelper # What a section says after its heading. Nearby is named for where the member is. def job_idea_section_note(section, member) place = member.location.to_s.split(',').first.to_s.strip - return t('job_ideas.section.nearby_note', place:, km: JobIdeas::Nearby::RADIUS_KM) if section == :nearby && place.present? + return t('job_ideas.section.nearby_note', place:) if section == :nearby && place.present? t("job_ideas.section.#{section}_note") end diff --git a/app/services/job_ideas.rb b/app/services/job_ideas.rb index d3bca05ad..21d5eaf24 100644 --- a/app/services/job_ideas.rb +++ b/app/services/job_ideas.rb @@ -21,7 +21,7 @@ class JobIdeas # Sources a member can switch off, by name, and the ones each name stands for. # Templates and harvests are the member's own garden, so they are always on. - SWITCHES = { 'mine' => %i(past_jobs past_plantings), 'community' => %i(community), 'nearby' => %i(nearby) }.freeze + SWITCHES = { 'mine' => %i(past_jobs past_plantings), 'community' => %i(community), 'nearby' => %i(nearby), 'world' => %i(world) }.freeze # How many ideas a member has, for the box over their jobs. It looks at other # people's jobs, so it is kept for a few minutes, until the member adds or hides @@ -64,7 +64,8 @@ class JobIdeas def sources { templates: JobIdeas::Templates, past_jobs: JobIdeas::PastJobs, past_plantings: JobIdeas::PastPlantings, - harvest: JobIdeas::Harvest, community: JobIdeas::Community, nearby: JobIdeas::Nearby + harvest: JobIdeas::Harvest, community: JobIdeas::Community, nearby: JobIdeas::Nearby, + world: JobIdeas::World }.except(*@skipped).values.map { |source| source.new(@member, @today) } end diff --git a/app/services/job_ideas/nearby.rb b/app/services/job_ideas/nearby.rb index e06fcd918..560d74ce2 100644 --- a/app/services/job_ideas/nearby.rb +++ b/app/services/job_ideas/nearby.rb @@ -1,21 +1,37 @@ # frozen_string_literal: true # What gardeners near a member have been doing, now and at this time of year -# before, whatever the crop. +# before, whatever the crop. Starts close and widens until there are enough +# gardeners to see a trend. class JobIdeas class Nearby < OthersJobs - RADIUS_KM = 25 + RADII_KM = [25, 50, 100, 250, 500].freeze + # How many gardeners to widen the search until there are. + ENOUGH = 10 private def owner_ids @owner_ids ||= if @member.latitude && @member.longitude - Member.confirmed.located.near([@member.latitude, @member.longitude], RADIUS_KM, units: :km).reorder(nil).pluck(:id) + within_distance else [] end end + def within_distance + found = [] + RADII_KM.each do |radius| + found = others.near([@member.latitude, @member.longitude], radius, units: :km).reorder(nil).pluck(:id) + return found if found.size >= ENOUGH + end + found + end + + def others + Member.confirmed.located.where.not(id: @member.id) + end + def kind :nearby end diff --git a/app/services/job_ideas/world.rb b/app/services/job_ideas/world.rb new file mode 100644 index 000000000..5d0f2c6d4 --- /dev/null +++ b/app/services/job_ideas/world.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# What gardeners anywhere at a similar latitude to a member did at this time of +# year, in this year and in earlier ones. The same latitude, in the same +# hemisphere, is the same season, wherever in the world they are. +class JobIdeas + class World < OthersJobs + # Degrees of latitude either side of the member. + BAND = 5 + + private + + def owner_ids + @owner_ids ||= if @member.latitude && @member.longitude + Member.confirmed.located.where.not(id: @member.id) + .where(latitude: (@member.latitude - BAND)..(@member.latitude + BAND)) + .where(@member.latitude.positive? ? 'latitude > 0' : 'latitude <= 0').reorder(nil).pluck(:id) + else + [] + end + end + + # This year as well as the years before. + def past_windows + [(@today - BEFORE)..@today] + super + end + + def kind + :world + end + end +end diff --git a/app/views/job_ideas/_list.html.haml b/app/views/job_ideas/_list.html.haml index 2fac3799e..b49f412e6 100644 --- a/app/views/job_ideas/_list.html.haml +++ b/app/views/job_ideas/_list.html.haml @@ -4,7 +4,7 @@ - on = @skipped.exclude?(name) = link_to job_idea_switch_path(name, @skipped), class: ['idea-filter', ('off' unless on)] do = on ? '✓' : '+' - = t("job_ideas.filter.#{name}", km: JobIdeas::Nearby::RADIUS_KM) + = t("job_ideas.filter.#{name}") - if @ideas.empty? %p.idea-none= t('job_ideas.none') - job_idea_sections(@ideas).each do |section, ideas| diff --git a/config/locales/en.yml b/config/locales/en.yml index 3b663f1b2..f96334fee 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -692,7 +692,8 @@ en: filter: mine: what I did before community: people growing what I grow - nearby: near me, within %{km} km + nearby: near me + world: world neighbours section: templates: Templates templates_note: for plantings you have @@ -703,7 +704,9 @@ en: community: People growing what you grow community_note: public jobs nearby: Near you - nearby_note: "%{place} and %{km} km around" + nearby_note: "%{place} and around" + world: World neighbours + world_note: gardeners at your latitude, anywhere none: Nothing to suggest right now. Check back as the season changes. add: "+ Add" add_all: Add all %{count} @@ -756,6 +759,11 @@ en: other: "%{count} gardeners nearby did this lately." earlier: other: "%{count} gardeners nearby did this at this time of year before." + world: + now: + other: "%{count} gardeners at your latitude did this lately." + earlier: + other: "%{count} gardeners at your latitude did this at this time of year." jobs: plan_something: Plan something celebrate: diff --git a/docs/adr/0001-world-neighbours.md b/docs/adr/0001-world-neighbours.md new file mode 100644 index 000000000..583642d25 --- /dev/null +++ b/docs/adr/0001-world-neighbours.md @@ -0,0 +1,50 @@ +# 1. World neighbours + +Date: 2026-10-24 + +## Status + +Accepted + +## Context + +Job suggestions include a "Near you" section: what gardeners within a set +distance of the member have been doing at this time of year. A fixed 25 km radius +often finds too few gardeners to show a trend, because a suggestion needs at +least `OthersJobs::MIN_GARDENERS` gardeners doing the same job. + +Distance is a poor proxy for what matters when gardening: the season. The season +follows latitude and hemisphere. A gardener 5,000 km east or west at the same +latitude is sowing and harvesting at about the same time. A gardener 500 km +north can be several weeks behind, and one in the other hemisphere is in the +opposite season. + +## Decision + +A **world neighbour** is a confirmed member with a known location whose latitude +is within `JobIdeas::World::BAND` degrees (5) of the member's, on the same side +of the equator. Longitude and distance don't matter. + +- The member's own jobs are left out, as are those of members they have blocked. +- World neighbours are their own section, "World neighbours", with their own + switch (`world`), separate from "Near you". +- It suggests the jobs world neighbours finished in the three weeks either side + of today's date in this year and each of the five before, plus the last two + weeks, when enough of them did the same one. This year is included, unlike + the other sources, so what neighbours are doing now counts. +- "Near you" stays about place. It widens its radius (25, 50, 100, 250, 500 km) + until there are `JobIdeas::Nearby::ENOUGH` gardeners, so a sparse area still + finds neighbours. + +## Consequences + +- Members in sparsely populated places get suggestions from gardeners who + share their season. +- Nearby gardeners can appear in both sections. Ideas have a key per source, so + hiding one doesn't hide the other. +- The band is in degrees of latitude, so it covers the same number of kilometres + north to south everywhere, but sits closer to the poles where climate changes + faster with latitude. Altitude, coast and local climate aren't considered. +- Members whose latitude is within `BAND` degrees of the equator only see + neighbours on their own side of it, even though the seasons there are less + distinct. diff --git a/spec/services/job_ideas/nearby_spec.rb b/spec/services/job_ideas/nearby_spec.rb index 8e0c82d52..d2b1f95ff 100644 --- a/spec/services/job_ideas/nearby_spec.rb +++ b/spec/services/job_ideas/nearby_spec.rb @@ -37,6 +37,14 @@ describe JobIdeas::Nearby do it { is_expected.to be_empty } end + context 'with gardeners a few hundred km away' do + let(:neighbours) { create_list(:london_member, 3, latitude: 53.5, longitude: -2.2) } + + it 'widens the search to find them' do + expect(ideas.map(&:name)).to eq ['Put out slug traps'] + end + end + context 'when the member has not said where they are' do let(:member) { create(:member) } diff --git a/spec/services/job_ideas/world_spec.rb b/spec/services/job_ideas/world_spec.rb new file mode 100644 index 000000000..62deb3da6 --- /dev/null +++ b/spec/services/job_ideas/world_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe JobIdeas::World do + subject(:ideas) { described_class.new(member, today).ideas } + + let(:today) { Date.new(2026, 10, 23) } + let(:member) { create(:london_member) } + let(:neighbours) { neighbours_at(51.5, -100.0) } + let(:due_date) { Date.new(2025, 10, 20) } + + # Saving a member geocodes their location again, so the coordinates are set after. + def neighbours_at(latitude, longitude) + create_list(:london_member, 3).each do |neighbour| + neighbour.update_columns(latitude:, longitude:) # rubocop:disable Rails/SkipsModelValidations + end + end + + before do + neighbours.each do |neighbour| + create(:job, :finished_without_time, owner: neighbour, name: 'Put out slug traps', due_date:) + end + end + + it 'has an idea for what enough gardeners at the same latitude did' do + expect(ideas.map(&:name)).to eq ['Put out slug traps'] + end + + it 'is a world idea' do + expect(ideas.first.kind).to eq :world + end + + context 'with a job done this year' do + let(:due_date) { Date.new(2026, 10, 12) } + + it 'counts it' do + expect(ideas.map(&:name)).to eq ['Put out slug traps'] + end + end + + context 'with gardeners at the opposite latitude' do + let(:neighbours) { neighbours_at(-51.5, 0.0) } + + it { is_expected.to be_empty } + end + + context 'with gardeners at a very different latitude in the same hemisphere' do + let(:neighbours) { neighbours_at(30.0, 0.0) } + + it { is_expected.to be_empty } + end + + context 'when the member has not said where they are' do + let(:member) { create(:member) } + + it { is_expected.to be_empty } + end +end From 168e3941508a02a542f95477e8d488c119ab2326 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 12:01:48 +1300 Subject: [PATCH 05/30] Expands "Friends and Neighbours" to 300 items --- app/controllers/neighbours_controller.rb | 2 +- app/services/neighbours.rb | 14 ++++++++------ spec/services/neighbours_spec.rb | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app/controllers/neighbours_controller.rb b/app/controllers/neighbours_controller.rb index 0b5028f66..16ecc1e06 100644 --- a/app/controllers/neighbours_controller.rb +++ b/app/controllers/neighbours_controller.rb @@ -7,7 +7,7 @@ class NeighboursController < ApplicationController def show current_member.feature_seen!(:neighbours) - @neighbours = Neighbours.new(current_member, scope: params[:scope]) + @neighbours = Neighbours.new(current_member, scope: params[:scope], limit: Neighbours::FIRST_FEED_EVENTS) end # The next small chunk of the feed, for "Show earlier days": its HTML, and diff --git a/app/services/neighbours.rb b/app/services/neighbours.rb index b7a01f809..a776ea017 100644 --- a/app/services/neighbours.rb +++ b/app/services/neighbours.rb @@ -19,6 +19,7 @@ class Neighbours POSTS_SHOWN = 5 FEED_KINDS = %i(planting harvest seed photo garden_layout transplant planned_job job_finished planting_finished seed_finished).freeze FEED_EVENTS = 15 + FIRST_FEED_EVENTS = 300 MAX_OFFSET = 500 PHOTO_SUBJECTS = %w(Planting Harvest Seed Garden Job).freeze EVENT_MODELS = { 'planting' => Planting, 'harvest' => Harvest, 'seed' => Seed, 'photo' => Photo, 'garden_layout' => Garden, @@ -53,12 +54,13 @@ class Neighbours end Day = Struct.new(:day, :updates, keyword_init: true) - attr_reader :member, :scope, :offset + attr_reader :member, :scope, :offset, :limit - def initialize(member, scope: nil, offset: 0) + def initialize(member, scope: nil, offset: 0, limit: FEED_EVENTS) @member = member @scope = SCOPES.include?(scope) ? scope : 'auto' @offset = offset.to_i.clamp(0, MAX_OFFSET) + @limit = limit end def shows_following? @@ -167,7 +169,7 @@ class Neighbours end def next_offset - offset + FEED_EVENTS + offset + limit end # Names a cached piece of the page for this member. It changes with who they @@ -273,14 +275,14 @@ class Neighbours def build_feed rows = feed_rows - @more_feed = rows.size > FEED_EVENTS - @feed = days_of(rows.first(FEED_EVENTS)) + @more_feed = rows.size > limit + @feed = days_of(rows.first(limit)) end def feed_rows return [] if feed_owner_ids.empty? - TimelineService.query(FEED_KINDS).where(owner_id: feed_owner_ids).offset(offset).limit(FEED_EVENTS + 1).to_a + TimelineService.query(FEED_KINDS).where(owner_id: feed_owner_ids).offset(offset).limit(limit + 1).to_a end def days_of(rows) diff --git a/spec/services/neighbours_spec.rb b/spec/services/neighbours_spec.rb index 05ee60242..c21426bfe 100644 --- a/spec/services/neighbours_spec.rb +++ b/spec/services/neighbours_spec.rb @@ -150,6 +150,20 @@ describe Neighbours do expect([shown, neighbours.more_feed?]).to eq [Neighbours::FEED_EVENTS, true] expect(described_class.new(member, offset: Neighbours::FEED_EVENTS).feed.flat_map(&:updates).sum { |entry| entry.items.size }).to eq 1 end + + context 'with a larger limit' do + subject(:larger) { described_class.new(member, limit: Neighbours::FEED_EVENTS + 5) } + + before { create_list(:planting, Neighbours::FEED_EVENTS + 5, owner: friend, garden: friend.gardens.first, planted_at: Date.new(2026, 10, 12)) } + + it 'shows that many events' do + expect(larger.feed.flat_map(&:updates).sum { |entry| entry.items.size }).to eq Neighbours::FEED_EVENTS + 5 + end + + it 'carries on after them' do + expect(larger.next_offset).to eq Neighbours::FEED_EVENTS + 5 + end + end end describe '#lede' do From b5a46fe2f2c13d3f93cbf3986891f4e2199913dd Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 12:26:14 +1300 Subject: [PATCH 06/30] Enhance job notepad functionality with date management and improved UI for done tasks --- app/assets/stylesheets/_jobs.scss | 36 ++++++++++++- app/javascript/site/job_notepad.js | 19 +++++++ app/services/job_list.rb | 57 +++++++++++++++++--- app/views/jobs/_actions.haml | 6 +-- app/views/jobs/_notepad_groups.html.haml | 19 +++++-- app/views/jobs/_notepad_row.html.haml | 12 ++++- app/views/jobs/_reschedule.haml | 10 ++++ config/locales/en.yml | 17 +++++- spec/services/job_list_spec.rb | 68 +++++++++++++++++++----- 9 files changed, 213 insertions(+), 31 deletions(-) create mode 100644 app/views/jobs/_reschedule.haml diff --git a/app/assets/stylesheets/_jobs.scss b/app/assets/stylesheets/_jobs.scss index 23fcb91a2..518e3572e 100644 --- a/app/assets/stylesheets/_jobs.scss +++ b/app/assets/stylesheets/_jobs.scss @@ -125,6 +125,13 @@ $notepad-trail: #a4c98c; margin: 0.75rem 1.1rem 0; } + .notepad-older summary { + color: $ink-muted; + cursor: pointer; + font-size: 0.9rem; + padding: 0.4rem 1.1rem; + } + .notepad-heading { display: flex; align-items: baseline; @@ -385,10 +392,37 @@ $notepad-trail: #a4c98c; } } - .notepad-due + .notepad-menu { + .notepad-due + .notepad-menu, + .notepad-day + .notepad-menu { margin-left: 0; } + .notepad-day { + position: relative; + margin-left: auto; + + .notepad-due { + margin-left: 0; + text-decoration: none; + + &.unset { + color: $ink-muted; + font-weight: 400; + } + } + } + + // The browser's calendar opens from this, beside the day; it is never seen itself. + .notepad-date-input { + position: absolute; + right: 0; + bottom: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; + } + // A snail crawling along a trail at the foot of the page, and how many jobs // are done this week. .notepad-snail { diff --git a/app/javascript/site/job_notepad.js b/app/javascript/site/job_notepad.js index a03e7d2c5..5953d2ec7 100644 --- a/app/javascript/site/job_notepad.js +++ b/app/javascript/site/job_notepad.js @@ -239,6 +239,25 @@ document.addEventListener('DOMContentLoaded', function () { startEditing(edit.closest('.notepad-job')); }); + // ---- Picking a day from the browser's calendar + + // "Change date" opens the browser's own calendar, which marks today, beside the + // job's day. Picking a day moves the job there. + groups.addEventListener('click', function (event) { + const change = event.target.closest('[data-notepad-change-date]'); + if (!change) return; + event.preventDefault(); + const input = change.closest('.notepad-day').querySelector('.notepad-date-input'); + if (input.showPicker) input.showPicker(); + else input.focus(); + }); + + groups.addEventListener('change', function (event) { + const input = event.target.closest('.notepad-date-input'); + if (!input || !input.value) return; + patchJob(input.closest('.notepad-job').dataset.url, {due_date: input.value}).catch(reload); + }); + // ---- Adding a job const link = document.getElementById('notepad-link'); diff --git a/app/services/job_list.rb b/app/services/job_list.rb index d302d052c..29232f0ed 100644 --- a/app/services/job_list.rb +++ b/app/services/job_list.rb @@ -1,16 +1,29 @@ # frozen_string_literal: true # A member's jobs laid out as a notepad: overdue, today, the rest of this week, -# soon, some day, and done. Today, this week and some day are always there, as -# places to drop a job; each says which day a job dropped in it gets. Overdue, -# soon and done have no day of their own, so nothing is dropped there. A job +# this weekend, next weekend, later, some day, and done. All but overdue and done +# are always there, as places to drop a job; each says which day a job dropped in +# it gets. Overdue and done have no day of their own, so nothing is dropped there. A job # crossed off stays where its day puts it, unless it has no day or that day has # gone: then it moves to the bottom, to done, the one finished last first. Jobs # crossed off in the page being looked at are pinned: they stay where they were -# until the page is loaded again, so a tick never makes a row jump. +# until the page is loaded again, so a tick never makes a row jump. Only the +# DONE_SHOWN most recently done are shown; the rest are older, behind "show more". class JobList + DONE_SHOWN = 5 + Group = Struct.new(:key, :jobs, :from, :to, :droppable, :drop_date, keyword_init: true) do alias_method :droppable?, :droppable + + # The jobs to show straight away. Only done is cut short. + def shown + key == :done ? jobs.first(DONE_SHOWN) : jobs + end + + # The jobs behind "show more". + def older + key == :done ? jobs.drop(DONE_SHOWN) : [] + end end # The last day of the week, a Sunday. @@ -18,6 +31,16 @@ class JobList today.end_of_week(:monday) end + # The first day of the weekend still to come: tomorrow, if today is the Saturday. + def self.weekend_start(today) + [week_end(today) - 1.day, today + 1.day].max + end + + # The day a job moved to Later gets: three weeks out. + def self.later_date(today) + today + 3.weeks + end + def initialize(jobs, today: Time.zone.today, pinned: []) @jobs = jobs @today = today @@ -31,13 +54,21 @@ class JobList done_undated, someday = undated.partition { |job| settled?(job) } done = (done_past + done_undated).sort_by { |job| done_time(job) }.reverse now, later = current.partition { |job| job.due_date == @today } - week, soon = later.partition { |job| ((@today + 1.day)..week_end).cover?(job.due_date) } + week, later = later.partition { |job| ((@today + 1.day)..friday).cover?(job.due_date) } + weekend, later = later.partition { |job| (weekend_from..week_end).cover?(job.due_date) } + next_weekend, later = later.partition { |job| ((saturday + 7.days)..(week_end + 7.days)).cover?(job.due_date) } [ (Group.new(key: :overdue, jobs: overdue) if overdue.any?), Group.new(key: :today, jobs: now, from: @today, droppable: true, drop_date: @today), - (Group.new(key: :week, jobs: week, from: @today + 1.day, to: week_end, droppable: true, drop_date: week_end) if week_end > @today), - (Group.new(key: :soon, jobs: soon) if soon.any?), + (Group.new(key: :week, jobs: week, from: @today + 1.day, to: friday, droppable: true, drop_date: friday) if friday > @today), + (if week_end > @today + Group.new(key: :weekend, jobs: weekend, from: weekend_from, to: week_end, droppable: true, + drop_date: weekend_from) + end), + Group.new(key: :next_weekend, jobs: next_weekend, from: saturday + 7.days, to: week_end + 7.days, droppable: true, + drop_date: saturday + 7.days), + Group.new(key: :later, jobs: later, droppable: true, drop_date: self.class.later_date(@today)), Group.new(key: :someday, jobs: someday, droppable: true), (Group.new(key: :done, jobs: done) if done.any?) ].compact @@ -59,4 +90,16 @@ class JobList def week_end self.class.week_end(@today) end + + def friday + week_end - 2.days + end + + def saturday + week_end - 1.day + end + + def weekend_from + self.class.weekend_start(@today) + end end diff --git a/app/views/jobs/_actions.haml b/app/views/jobs/_actions.haml index 0b7033136..d9d5fc589 100644 --- a/app/views/jobs/_actions.haml +++ b/app/views/jobs/_actions.haml @@ -12,8 +12,8 @@ -# The notepad has a tick box for this. - if job.active && !local_assigns[:inline_edit] = menu_link_to :finish, t('buttons.mark_as_finished'), update_path.call(finished: 1), method: :put, class: 'append-date' - = menu_link_to :job, t('jobs.pad.move_today'), update_path.call(due_date: Time.zone.today), method: :put - - if JobList.week_end(Time.zone.today) > Time.zone.today - = menu_link_to :job, t('jobs.pad.move_week'), update_path.call(due_date: JobList.week_end(Time.zone.today)), method: :put + -# On the notepad these are under the job's day. + - unless local_assigns[:inline_edit] + = render('jobs/reschedule', job:) .dropdown-divider = menu_delete_button(job) diff --git a/app/views/jobs/_notepad_groups.html.haml b/app/views/jobs/_notepad_groups.html.haml index 667c129c5..9c58e0b49 100644 --- a/app/views/jobs/_notepad_groups.html.haml +++ b/app/views/jobs/_notepad_groups.html.haml @@ -5,11 +5,24 @@ %h2= t("jobs.pad.#{group.key}") - if group.key == :today %small= l(group.from, format: '%a %-d %b') - - elsif group.key == :week && group.from <= group.to + - elsif %i(week weekend next_weekend).include?(group.key) && group.from <= group.to %small= "#{l(group.from, format: '%a %-d')} – #{l(group.to, format: '%a %-d %b')}" - elsif group.key == :someday %small= t('jobs.pad.someday_note') - if group.jobs.empty? %p.notepad-drop-hint= t('jobs.pad.drop_here') - - group.jobs.each do |job| - = render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden] + -# Later is rolled up until asked for: it is not for today's list. + - rows = capture do + - group.shown.each do |job| + = render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden] + - if group.key == :later && group.jobs.any? + %details.notepad-older + %summary= t('jobs.pad.show_later', count: group.jobs.size) + = rows + - else + = rows + - if group.older.any? + %details.notepad-older + %summary= t('jobs.pad.show_older', count: group.older.size) + - group.older.each do |job| + = render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden] diff --git a/app/views/jobs/_notepad_row.html.haml b/app/views/jobs/_notepad_row.html.haml index dfd9ecf87..9f7822b7b 100644 --- a/app/views/jobs/_notepad_row.html.haml +++ b/app/views/jobs/_notepad_row.html.haml @@ -28,6 +28,16 @@ = link_to photo_path(photo), class: 'notepad-photo', title: t("jobs.pad.photo_of_#{moment}", title: photo.title) do = image_tag photo.thumbnail_url, alt: t("jobs.pad.photo_of_#{moment}", title: photo.title) %span.notepad-photo-moment= t("jobs.pad.#{moment}") - - if date_label + - if job.finished + - if date_label + %span.notepad-due{ class: ('late' if job_overdue?(job)) }= date_label + - elsif can?(:edit, job) + -# The day is a menu: move the job to a day, or pick one from a calendar. + .dropdown.notepad-day + %a.notepad-due{ href: '#', role: 'button', class: [('late' if job_overdue?(job)), ('unset' unless date_label)], 'aria-haspopup': 'true', 'aria-expanded': 'false', + title: t('jobs.pad.change_day'), data: { 'bs-toggle': 'dropdown' } }= date_label || t('jobs.pad.set_day') + .dropdown-menu.dropdown-menu-end= render 'jobs/reschedule', job:, inline_edit: true + %input.notepad-date-input{ type: 'date', value: job.due_date&.to_fs(:ymd), tabindex: -1, 'aria-label': t('jobs.pad.change_date') } + - elsif date_label %span.notepad-due{ class: ('late' if job_overdue?(job)) }= date_label .notepad-menu= render 'jobs/actions', job:, inline_edit: true diff --git a/app/views/jobs/_reschedule.haml b/app/views/jobs/_reschedule.haml new file mode 100644 index 000000000..4b2ea5f35 --- /dev/null +++ b/app/views/jobs/_reschedule.haml @@ -0,0 +1,10 @@ +-# The ways to move a job to another day. On the notepad they sit under the job's day. +- update_path = ->(attributes) { job_path(job, job: attributes) } += menu_link_to :job, t('jobs.pad.move_today'), update_path.call(due_date: Time.zone.today), method: :put += menu_link_to :job, t('jobs.pad.move_tomorrow'), update_path.call(due_date: Time.zone.tomorrow), method: :put +- if JobList.weekend_start(Time.zone.today) > Time.zone.tomorrow + = menu_link_to :job, t('jobs.pad.move_weekend'), update_path.call(due_date: JobList.weekend_start(Time.zone.today)), method: :put += menu_link_to :job, t('jobs.pad.move_later'), update_path.call(due_date: JobList.later_date(Time.zone.today)), method: :put +-# On the notepad the script opens the browser's own calendar, which marks today. +- if local_assigns[:inline_edit] + = menu_link_to :job, t('jobs.pad.change_date'), edit_job_path(job), data: { notepad_change_date: true } diff --git a/config/locales/en.yml b/config/locales/en.yml index f96334fee..478f401e0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -803,6 +803,12 @@ en: adding: "Adding “%{name}”…" edit_label: Job name edit_hint: Enter to save, Esc to cancel + show_later: + one: Show 1 job coming up + other: Show %{count} jobs coming up + show_older: + one: Show 1 older done job + other: Show %{count} older done jobs add_failed: That job may not have been added. Check your list before you try again. public_note: Jobs are public, so don't put private info here. overdue: Overdue @@ -817,12 +823,19 @@ en: done_today: done today today: Today week: This week - soon: Soon + weekend: This weekend + next_weekend: Next weekend + later: Later someday: Some day drop_here: Drag a job here. slow: Slow and steady. move_today: Today - move_week: This week + change_day: Change the day + set_day: Set a day + change_date: Change date… + move_tomorrow: Tomorrow + move_weekend: This weekend + move_later: Later someday_note: a wishlist, nothing committed garden: Garden crop: Crop diff --git a/spec/services/job_list_spec.rb b/spec/services/job_list_spec.rb index 97e5c22eb..8fd2dfaf7 100644 --- a/spec/services/job_list_spec.rb +++ b/spec/services/job_list_spec.rb @@ -5,11 +5,14 @@ require 'rails_helper' describe JobList do subject(:list) { described_class.new(jobs, today:) } - # A Wednesday, so the rest of the week is Thursday to Sunday the 25th. + # A Wednesday, so the rest of the week is Thursday and Friday, then the weekend + # of the 24th and 25th. let(:today) { Date.new(2026, 10, 21) } - let(:jobs) { [due_today, saturday, next_week, overdue, no_day] } + let(:jobs) { [due_today, thursday, saturday, next_saturday, next_week, overdue, no_day] } + let(:thursday) { build(:job, due_date: Date.new(2026, 10, 22)) } let(:due_today) { build(:job, due_date: today) } let(:saturday) { build(:job, due_date: Date.new(2026, 10, 24)) } + let(:next_saturday) { build(:job, due_date: Date.new(2026, 10, 31)) } let(:next_week) { build(:job, due_date: Date.new(2026, 10, 28)) } let(:overdue) { build(:job, due_date: Date.new(2026, 10, 19)) } let(:no_day) { build(:job, due_date: nil) } @@ -19,7 +22,7 @@ describe JobList do end it 'shows the groups in order' do - expect(list.groups.map(&:key)).to eq %i(overdue today week soon someday) + expect(list.groups.map(&:key)).to eq %i(overdue today week weekend next_weekend later someday) end it 'puts jobs due today in Today' do @@ -27,15 +30,23 @@ describe JobList do end it 'puts jobs for the rest of the week in This week' do - expect(group(:week).jobs).to eq [saturday] + expect(group(:week).jobs).to eq [thursday] + end + + it 'puts jobs for Saturday and Sunday in This weekend' do + expect(group(:weekend).jobs).to eq [saturday] + end + + it 'puts jobs for the weekend after in Next weekend' do + expect(group(:next_weekend).jobs).to eq [next_saturday] end it 'puts jobs from before today in Overdue' do expect(group(:overdue).jobs).to eq [overdue] end - it 'puts jobs for after this week in Soon' do - expect(group(:soon).jobs).to eq [next_week] + it 'puts jobs for after next weekend in Later' do + expect(group(:later).jobs).to eq [next_week] end it 'puts jobs without a day in Some day' do @@ -52,7 +63,7 @@ describe JobList do let(:jobs) { [due_today, overdue, done, done_later] } it 'puts it in Done, at the bottom' do - expect(list.groups.map(&:key)).to eq %i(overdue today week someday done) + expect(list.groups.map(&:key)).to eq %i(overdue today week weekend next_weekend later someday done) end it 'leaves it out of Overdue' do @@ -125,7 +136,7 @@ describe JobList do let(:jobs) { [] } it 'still has places to drop one' do - expect(list.groups.map(&:key)).to eq %i(today week someday) + expect(list.groups.map(&:key)).to eq %i(today week weekend next_weekend later someday) end end @@ -134,16 +145,24 @@ describe JobList do expect(group(:today).drop_date).to eq today end - it 'is the Sunday for This week' do - expect(group(:week).drop_date).to eq Date.new(2026, 10, 25) + it 'is the Friday for This week' do + expect(group(:week).drop_date).to eq Date.new(2026, 10, 23) + end + + it 'is the Saturday for This weekend' do + expect(group(:weekend).drop_date).to eq Date.new(2026, 10, 24) + end + + it 'is the next Saturday for Next weekend' do + expect(group(:next_weekend).drop_date).to eq Date.new(2026, 10, 31) end it 'is no day for Some day' do expect(group(:someday).drop_date).to be_nil end - it 'is nothing for Soon' do - expect(group(:soon)).not_to be_droppable + it 'is three weeks out for Later' do + expect(group(:later).drop_date).to eq Date.new(2026, 11, 11) end end @@ -151,8 +170,29 @@ describe JobList do let(:today) { Date.new(2026, 10, 25) } let(:jobs) { [due_today] } - it 'has no This week, as none of the week is left' do - expect(list.groups.map(&:key)).to eq %i(today someday) + it 'has no This week or This weekend, as none of the week is left' do + expect(list.groups.map(&:key)).to eq %i(today next_weekend later someday) + end + end + + context 'when today is Saturday' do + let(:today) { Date.new(2026, 10, 24) } + let(:jobs) { [] } + + it 'drops on Sunday for This weekend' do + expect(group(:weekend).drop_date).to eq Date.new(2026, 10, 25) + end + end + + context 'with more than five crossed off jobs' do + let(:jobs) { Array.new(7) { |i| build(:job, due_date: today - (i + 1).days, finished: true) } } + + it 'shows the five most recently done' do + expect(group(:done).shown).to eq jobs.first(5) + end + + it 'keeps the rest as older' do + expect(group(:done).older).to eq jobs.last(2) end end end From e0b24f860b658691981fe3d387c3138f5cbee3ba Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sat, 24 Oct 2026 13:47:00 +1030 Subject: [PATCH 07/30] Add rel=nofollow to member website link when member has no plantings (#5056) * Add rel=nofollow to member website link when member has no plantings Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com> * Add rel=nofollow to member website and other URL links when user has no plantings Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com> * Remove low value coverage --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/views/members/_contact.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/members/_contact.html.haml b/app/views/members/_contact.html.haml index 348cbce35..b56e746e5 100644 --- a/app/views/members/_contact.html.haml +++ b/app/views/members/_contact.html.haml @@ -4,7 +4,7 @@ - if member.website_url.present? %p = icon 'fas', 'globe', class: 'fa-fw' - = link_to "Website", member.website_url, target: '_blank', rel: 'noopener noreferrer' + = link_to "Website", member.website_url, target: '_blank', rel: (member.plantings.empty? ? 'noopener noreferrer nofollow' : 'noopener noreferrer') - if member.instagram_handle.present? %p @@ -24,7 +24,7 @@ - if member.other_url.present? %p = icon 'fas', 'link', class: 'fa-fw' - = link_to "More...", member.other_url, target: '_blank', rel: 'noopener noreferrer' + = link_to "More...", member.other_url, target: '_blank', rel: (member.plantings.empty? ? 'noopener noreferrer nofollow' : 'noopener noreferrer') - if flickr_auth %p From d491df87fcee83f0dbd347d399cfe9d2c40697f3 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sat, 24 Oct 2026 14:30:50 +1030 Subject: [PATCH 08/30] Filter deleted members by default and enable bulk ban on admin members (#5060) * Filter deleted members by default and enable bulk ban on admin members Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com> * Drop instagram * Default sort * Update specs --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- app/controllers/admin/members_controller.rb | 26 +++- app/views/admin/members/index.html.haml | 151 +++++++++++++++++--- config/routes.rb | 5 +- spec/requests/admin/members_spec.rb | 90 ++++++++++++ 4 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 spec/requests/admin/members_spec.rb diff --git a/app/controllers/admin/members_controller.rb b/app/controllers/admin/members_controller.rb index 781d88e86..97406e67f 100644 --- a/app/controllers/admin/members_controller.rb +++ b/app/controllers/admin/members_controller.rb @@ -8,8 +8,10 @@ module Admin responders :flash def index - @members = Member.order(:login_name) - @members = @members.where("login_name ILIKE ?", "%#{search_term}%") if search_term.present? + @status = params[:status] == 'deactivated' ? 'deactivated' : 'active' + @members = @status == 'deactivated' ? Member.discarded : Member.kept + @members = @members.includes(:roles).order(created_at: :desc) + @members = @members.where("login_name ILIKE ? OR email ILIKE ?", "%#{search_term}%", "%#{search_term}%") if search_term.present? @members = @members.paginate(page: params[:page]) end @@ -27,6 +29,26 @@ module Admin def destroy @member = Member.find_by!(slug: params[:slug]) @member.discard + redirect_to admin_members_path(status: params[:status]) + end + + def restore + @member = Member.find_by!(slug: params[:slug]) + @member.undiscard + redirect_to admin_members_path(status: 'deactivated') + end + + def bulk_ban + member_ids = params[:member_ids] + if member_ids.present? && member_ids.is_a?(Array) + members_to_ban = Member.kept.where(id: member_ids) + # Prevent admins from self-banning via bulk ban + members_to_ban = members_to_ban.where.not(id: current_member.id) + members_to_ban.find_each(&:discard) + flash[:notice] = "Selected members have been banned." + else + flash[:alert] = "No members were selected." + end redirect_to admin_members_path end diff --git a/app/views/admin/members/index.html.haml b/app/views/admin/members/index.html.haml index b5eca1c76..76cfaf01f 100644 --- a/app/views/admin/members/index.html.haml +++ b/app/views/admin/members/index.html.haml @@ -2,32 +2,141 @@ %li.breadcrumb-item= link_to 'Admin', admin_path %li.breadcrumb-item.active= link_to 'Members', admin_members_path -.row +.row.mb-3 .col-md-6 = bootstrap_form_tag url: admin_members_path, method: :get, layout: :horizontal do |f| + = hidden_field_tag :status, @status .input-group %span.input-group-text= icon 'fas', 'user' - = f.text_field(:q, 'aria-label': 'search members', placeholder: 'search members', hide_label: true) + = f.text_field(:q, value: params[:q], 'aria-label': 'search members', placeholder: 'search members', hide_label: true) = f.submit 'search', class: 'btn btn-secondary' -%table.table.table-striped - %thead - %tr - %th{scope: "col"} # - %th{scope: "col"} Login Name - - @members.each do |member| - %tr - %td= render 'members/tiny', member: member - %td= member.login_name - %td - %ul - - member.roles.pluck(:name).each do |r| - %li= r - %td - = link_to edit_admin_member_path(member), class: 'btn' do - = edit_icon - = link_to admin_member_path(member), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-light text-danger' do - = icon 'fas', 'ban' - Ban member +%ul.nav.nav-tabs.mb-3 + %li.nav-item + = link_to 'Active', admin_members_path(status: 'active', q: params[:q]), class: "nav-link #{@status == 'active' ? 'active' : ''}" + %li.nav-item + = link_to 'Deactivated', admin_members_path(status: 'deactivated', q: params[:q]), class: "nav-link #{@status == 'deactivated' ? 'active' : ''}" + +- if @status == 'active' + = form_tag bulk_ban_admin_members_path, method: :post, id: 'bulk_ban_form' do + .d-flex.justify-content-between.align-items-center.mb-2 + %div + = submit_tag 'Bulk Ban Selected', class: 'btn btn-danger btn-sm', data: { confirm: 'Are you sure you want to ban the selected members?' } + + .table-responsive + %table.table.table-striped.table-sm.align-middle + %thead + %tr + %th{scope: "col"} + %input#select_all_members{type: "checkbox", onclick: "document.querySelectorAll('.member-select-checkbox').forEach(cb => cb.checked = this.checked);"} + %th{scope: "col"} # + %th{scope: "col"} Login Name + %th{scope: "col"} Email + %th{scope: "col"} Confirmed + %th{scope: "col"} Last Login + %th{scope: "col"} Bio + %th{scope: "col"} Roles + %th{scope: "col"} Photos + %th{scope: "col"} Plantings + %th{scope: "col"} Forums + %th{scope: "col"} Gardens + %th{scope: "col"} Harvests + %th{scope: "col"} Jobs + %th{scope: "col"} Actions + %tbody + - @members.each do |member| + %tr + %td + - unless member.id == current_member.id + = check_box_tag "member_ids[]", member.id, false, class: 'member-select-checkbox' + %td= render 'members/tiny', member: member + %td= link_to member.login_name, member + %td= member.email + %td + - if member.confirmed? + %span.badge.bg-success Yes + - else + %span.badge.bg-secondary No + %td + - if member.last_sign_in_at.present? + %small= l(member.last_sign_in_at, format: :short) + - else + %small.text-muted Never + %td + - if member.bio.present? + %small{title: member.bio}= truncate(member.bio, length: 255) + %td + - if member.roles.any? + %small= member.roles.map(&:name).join(', ') + %td= member.photos_count || 0 + %td= member.plantings_count || 0 + %td= member.forums_count || 0 + %td= member.gardens_count || 0 + %td= member.harvests_count || 0 + %td= member.jobs_count || 0 + %td + .btn-group.btn-group-sm + = link_to edit_admin_member_path(member), class: 'btn btn-outline-secondary', title: 'Edit roles' do + = edit_icon + - unless member.id == current_member.id + = link_to admin_member_path(member, status: @status), method: :delete, data: { confirm: 'Are you sure you want to ban this member?' }, class: 'btn btn-outline-danger', title: 'Ban member' do + = icon 'fas', 'ban' + +- else + .table-responsive + %table.table.table-striped.table-sm.align-middle + %thead + %tr + %th{scope: "col"} # + %th{scope: "col"} Login Name + %th{scope: "col"} Email + %th{scope: "col"} Confirmed + %th{scope: "col"} Last Login + %th{scope: "col"} Bio + %th{scope: "col"} Instagram + %th{scope: "col"} Roles + %th{scope: "col"} Photos + %th{scope: "col"} Plantings + %th{scope: "col"} Forums + %th{scope: "col"} Gardens + %th{scope: "col"} Harvests + %th{scope: "col"} Jobs + %th{scope: "col"} Actions + %tbody + - @members.each do |member| + %tr + %td= render 'members/tiny', member: member + %td= member.login_name + %td= member.email + %td + - if member.confirmed? + %span.badge.bg-success Yes + - else + %span.badge.bg-secondary No + %td + - if member.last_sign_in_at.present? + %small= l(member.last_sign_in_at, format: :short) + - else + %small.text-muted Never + %td + - if member.bio.present? + %small{title: member.bio}= truncate(member.bio, length: 255) + %td + - if member.instagram_handle.present? + %small= member.instagram_handle + %td + - if member.roles.any? + %small= member.roles.map(&:name).join(', ') + %td= member.photos_count || 0 + %td= member.plantings_count || 0 + %td= member.forums_count || 0 + %td= member.gardens_count || 0 + %td= member.harvests_count || 0 + %td= member.jobs_count || 0 + %td + = link_to restore_admin_member_path(member), method: :post, data: { confirm: 'Restore this member?' }, class: 'btn btn-sm btn-outline-success', title: 'Restore member' do + = icon 'fas', 'undo' + Restore + = page_entries_info @members = will_paginate @members diff --git a/config/routes.rb b/config/routes.rb index 362952bb7..7f95fdeaf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -185,7 +185,10 @@ Rails.application.routes.draw do end namespace :admin do - resources :members, param: :slug + resources :members, param: :slug do + post :bulk_ban, on: :collection + post :restore, on: :member + end resources :roles resources :crops, param: :slug do resources :crop_companions diff --git a/spec/requests/admin/members_spec.rb b/spec/requests/admin/members_spec.rb new file mode 100644 index 000000000..ef21906ce --- /dev/null +++ b/spec/requests/admin/members_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin::Members', type: :request do + let(:admin_role) { create(:role, name: 'admin') } + let(:admin) { create(:member, roles: [admin_role]) } + let!(:active_member) do + create( + :member, + login_name: 'active_spammer', + email: 'active@example.com', + bio: 'A' * 300, + photos_count: 3, + plantings_count: 5, + forums_count: 1, + gardens_count: 2, + harvests_count: 4, + jobs_count: 6, + confirmed_at: Time.current, + last_sign_in_at: Time.current + ) + end + let!(:deleted_member) do + member = create(:member, login_name: 'deleted_user', email: 'deleted@example.com') + member.discard + member + end + + before do + login_as admin, scope: :member + end + + describe 'GET /admin/members' do + context 'by default (Active tab)' do + it 'filters out deleted members and shows active members' do + get admin_members_path + expect(response).to have_http_status(:ok) + expect(response.body).to include('active_spammer') + expect(response.body).not_to include('deleted_user') + end + + it 'surfaces login name, email, confirmed status, last login, truncated bio, and count attributes' do + get admin_members_path + expect(response.body).to include('active@example.com') + expect(response.body).to include('A' * 252 + '...') # truncated to 255 chars + expect(response.body).to include('Photos') + expect(response.body).to include('Plantings') + expect(response.body).to include('Forums') + expect(response.body).to include('Gardens') + expect(response.body).to include('Harvests') + expect(response.body).to include('Jobs') + end + end + + context 'when requesting deactivated tab (status=deactivated)' do + it 'shows discarded members and excludes active members' do + get admin_members_path(status: 'deactivated') + expect(response).to have_http_status(:ok) + expect(response.body).to include('deleted_user') + expect(response.body).not_to include('active_spammer') + end + end + end + + describe 'POST /admin/members/bulk_ban' do + it 'discards selected active members' do + expect do + post bulk_ban_admin_members_path, params: { member_ids: [active_member.id] } + end.to change { active_member.reload.discarded? }.from(false).to(true) + + expect(response).to redirect_to(admin_members_path) + end + + it 'does not allow admin to ban themselves via bulk ban' do + post bulk_ban_admin_members_path, params: { member_ids: [admin.id] } + expect(admin.reload.discarded?).to be false + end + end + + describe 'POST /admin/members/:slug/restore' do + it 'undiscards a deactivated member' do + expect do + post restore_admin_member_path(deleted_member) + end.to change { deleted_member.reload.discarded? }.from(true).to(false) + + expect(response).to redirect_to(admin_members_path(status: 'deactivated')) + end + end +end From 7d2a1cf0ed6d752a68da4924f3c446b8b69d13d1 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 17:17:16 +1300 Subject: [PATCH 09/30] Update Node.js engine requirement to 24.x in package.json (#5047) * Update Node.js engine requirement to 24.x in package.json * Use Node.js 24 in Docker image Co-authored-by: Br3nda <12266+Br3nda@users.noreply.github.com> * Add Copilot --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Daniel O'Connor --- CONTRIBUTORS.md | 3 ++- Dockerfile | 2 +- package.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e2d7259f0..6e9126e87 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -98,8 +98,9 @@ submit the change with your pull request. ## Bots -### Security and Dependency Updates +### Agents, Security and Dependency Updates - `codefactor-io[bot]` - DeppBot / [deppbot](https://github.com/deppbot) - `dependabot[bot]` [dependabot](https://github.com/dependabot-bot) / [dependabot-preview](https://github.com/apps/dependabot-preview) - [google-labs-jules[bot]](https://github.com/apps/google-labs-jules) +- Copilot \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3804b623f..b1e8e026a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN apt-get update -qq && \ gnupg2 \ shared-mime-info \ libvips \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && npm install -g yarn \ && apt-get clean \ diff --git a/package.json b/package.json index a2a486188..15d8ce9ae 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "sass": "^1.105.2" }, "engines": { - "node": ">=6.0.0", + "node": "24.x", "yarn": ">=0.25.2" }, "devDependencies": { From 0f0de4f0fddf271750fcf1b6adb927151ec3d03b Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 16:05:53 +1300 Subject: [PATCH 10/30] Update Ruby to 4.0.9 --- .ruby-version | 2 +- Dockerfile | 2 +- Gemfile.lock | 2 +- mise.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.ruby-version b/.ruby-version index 43beb4001..7919852fe 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.7 +4.0.9 diff --git a/Dockerfile b/Dockerfile index b1e8e026a..dfd793a5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:4.0.7-trixie +FROM ruby:4.0.9-trixie # Install system dependencies RUN apt-get update -qq && \ diff --git a/Gemfile.lock b/Gemfile.lock index 53d7d63a4..d9061044b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -732,7 +732,7 @@ DEPENDENCIES xmlrpc RUBY VERSION - ruby 4.0.7 + ruby 4.0.9 BUNDLED WITH 4.0.7 diff --git a/mise.toml b/mise.toml index 2a3c2dedf..b0418400b 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,3 @@ [tools] -ruby = "4.0.7" +ruby = "4.0.9" node = "24.19.0" From 8e0825b1b01af0a813ce33860c0205fd5fbf4464 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 17:31:37 +1300 Subject: [PATCH 11/30] Add job finished event handling and descriptions - Implemented event description logic for finished jobs in EventHelper. - Created a new partial for finished job descriptions. - Updated TimelineService to include finished jobs in event queries. - Enhanced specs for event descriptions and timeline service to cover finished jobs. --- app/helpers/event_helper.rb | 7 +++-- app/helpers/icons_helper.rb | 4 +++ app/services/timeline_service.rb | 15 ++-------- app/views/jobs/_description.haml | 7 ++++- app/views/jobs/_finished_description.haml | 7 +++++ spec/helpers/event_helper_spec.rb | 35 +++++++++++++++++++++++ spec/services/timeline_service_spec.rb | 12 ++++++++ 7 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 app/views/jobs/_finished_description.haml diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index e349b06ff..b51040401 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -5,13 +5,16 @@ module EventHelper (days / 7.0).round end + # A "job_finished" event is described by jobs/_finished_description. def event_description(event) - render "#{event.event_type.pluralize}/description", event_model: resolve_model(event) + model = resolve_model(event) + partial = event.event_type.end_with?('_finished') ? 'finished_description' : 'description' + render "#{model.model_name.collection}/#{partial}", event_model: model rescue ActionView::MissingTemplate "#{event.event_type.humanize.downcase}d" end def resolve_model(event) - event.event_type.classify.constantize.find(event.id) + event.event_type.delete_suffix('_finished').classify.constantize.find(event.id) end end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 4ef03d622..a8c187850 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -82,6 +82,10 @@ module IconsHelper icon('fas', 'note-sticky') end + def job_finished_icon + finished_icon + end + def post_icon image_icon 'post' end diff --git a/app/services/timeline_service.rb b/app/services/timeline_service.rb index a5176e805..085207d50 100644 --- a/app/services/timeline_service.rb +++ b/app/services/timeline_service.rb @@ -18,7 +18,6 @@ class TimelineService comment: :comments_query, photo: :photos_query, seed: :seeds_query, - job: :jobs_query, planned_job: :planned_jobs_query, job_finished: :jobs_finished_query, like: :likes_query, @@ -27,11 +26,11 @@ class TimelineService planting_finished: :plantings_finished_query, seed_finished: :seeds_finished_query }.freeze - DEFAULT_EVENTS = %i(planting harvest post comment photo seed job like).freeze + DEFAULT_EVENTS = %i(planting harvest post comment photo seed planned_job job_finished like).freeze # Events of the given kinds, newest first. Garden layouts are only here when # asked for, since the timeline page has nothing to say about them; the same goes - # for transplants, planned and finished jobs, and finished plantings and seeds. + # for transplants, and finished plantings and seeds. def self.query(kinds = DEFAULT_EVENTS) kinds.map { |kind| public_send(EVENT_QUERIES.fetch(kind)) } .reduce { |all, one| all.union_all(one) } @@ -81,16 +80,6 @@ class TimelineService Job.select(:id, "'job' as event_type", 'jobs.created_at as event_at', :owner_id, 'null as crop_id') end - def self.jobs_query - Job.select( - :id, - "'job' as event_type", - 'COALESCE(jobs.due_date, jobs.created_at) as event_at', - :owner_id, - 'null as crop_id' - ) - end - def self.plantings_query Planting.select( :id, diff --git a/app/views/jobs/_description.haml b/app/views/jobs/_description.haml index dc6356bae..b0d1176c0 100644 --- a/app/views/jobs/_description.haml +++ b/app/views/jobs/_description.haml @@ -1,4 +1,9 @@ -#{event_model.name} +- if event_model.finished + planned to do “#{event_model.name}” +- else + plans to do “#{event_model.name}” + - if event_model.due_date && event_model.due_date >= Time.zone.today + (due #{job_due_label(event_model)}) - if event_model.planting %span on #{link_to event_model.planting, event_model.planting} diff --git a/app/views/jobs/_finished_description.haml b/app/views/jobs/_finished_description.haml new file mode 100644 index 000000000..a441e4e8d --- /dev/null +++ b/app/views/jobs/_finished_description.haml @@ -0,0 +1,7 @@ +did “#{event_model.name}” + +- if event_model.planting + %span on #{link_to event_model.planting, event_model.planting} + +- if event_model.garden + %span in #{link_to event_model.garden, event_model.garden} diff --git a/spec/helpers/event_helper_spec.rb b/spec/helpers/event_helper_spec.rb index 21ec50d94..c85c46987 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -54,6 +54,41 @@ RSpec.describe EventHelper do it { expect(event_description(event)).to have_text "took a photo" } end + describe 'jobs' do + let(:job) { create(:job, name: 'Weed the beds', due_date:) } + let(:due_date) { nil } + let(:event) { OpenStruct.new(id: job.id, event_type: 'job') } + + it { expect(subject).to eq job } + it { expect(event_description(event)).to have_text 'plans to do “Weed the beds”' } + + context 'when due later' do + let(:due_date) { 3.months.from_now.to_date } + + it { expect(event_description(event)).to have_text "(due #{l(due_date, format: '%a %-d %b')})" } + end + + context 'when overdue' do + let(:due_date) { 1.month.ago.to_date } + + it { expect(event_description(event)).to have_no_text 'due' } + end + + context 'when finished' do + let(:job) { create(:job, name: 'Weed the beds', finished: true) } + + it { expect(event_description(event)).to have_text 'planned to do “Weed the beds”' } + end + end + + describe 'finished jobs' do + let(:job) { create(:job, name: 'Weed the beds', finished: true) } + let(:event) { OpenStruct.new(id: job.id, event_type: 'job_finished') } + + it { expect(subject).to eq job } + it { expect(event_description(event)).to have_text 'did “Weed the beds”' } + end + describe 'in_weeks' do it { expect(in_weeks(14)).to eq 2 } it { expect(in_weeks(15)).to eq 2 } diff --git a/spec/services/timeline_service_spec.rb b/spec/services/timeline_service_spec.rb index 6e14e7553..0832a87ff 100644 --- a/spec/services/timeline_service_spec.rb +++ b/spec/services/timeline_service_spec.rb @@ -30,6 +30,18 @@ describe TimelineService do it { expect(subject.first.event_type).to eq 'seed' } it { expect(subject.second.id).to eq my_post.id } it { expect(subject.second.event_type).to eq 'post' } + + context 'with a job due later' do + let!(:my_job) { create(:job, owner: member, due_date: 3.months.from_now, created_at: 5.days.ago) } + + it { expect(subject.second.id).to eq my_job.id } + end + + context 'with a finished job' do + let!(:my_job) { create(:job, owner: member, finished: true, created_at: 5.days.ago) } + + it { expect(subject.first.event_type).to eq 'job_finished' } + end end describe "everyone's timeline" do From 85b5ff9ecdcd009fcca56dea18712811df4d12bf Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 17:21:44 +1300 Subject: [PATCH 12/30] Add feature flags, with job ideas as the first Admins switch features on and off at /admin/features, kept in Flipper. Job ideas (the page, the banners and the overlay) only show while its flag is on. Features are off until switched on. Closes #5050 --- Gemfile | 1 + Gemfile.lock | 6 + app/controllers/admin/features_controller.rb | 19 ++++ app/controllers/job_ideas_controller.rb | 3 +- app/models/feature.rb | 40 +++++++ app/views/admin/features/index.html.haml | 31 +++++ app/views/admin/index.html.haml | 1 + app/views/home/dashboard/_ideas.html.haml | 4 +- app/views/jobs/_ideas.html.haml | 9 +- config/locales/en.yml | 18 +++ config/routes.rb | 1 + .../20261024031447_create_flipper_tables.rb | 25 +++++ db/schema.rb | 18 ++- .../admin/features_controller_spec.rb | 106 ++++++++++++++++++ ...me_controller_index_with_job_ideas_spec.rb | 13 ++- spec/requests/job_ideas_controller_spec.rb | 10 +- .../jobs_controller_index_with_ideas_spec.rb | 17 ++- spec/support/flipper.rb | 11 ++ 18 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 app/controllers/admin/features_controller.rb create mode 100644 app/models/feature.rb create mode 100644 app/views/admin/features/index.html.haml create mode 100644 db/migrate/20261024031447_create_flipper_tables.rb create mode 100644 spec/requests/admin/features_controller_spec.rb create mode 100644 spec/support/flipper.rb diff --git a/Gemfile b/Gemfile index 2a1704f04..b20559463 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,7 @@ gem 'flickraw' gem 'cancancan' # for checking member privileges gem 'csv_shaper' # CSV export +gem 'flipper-active_record' # feature flags admins switch on and off gem 'gibbon', '~>1.2.0' # for Mailchimp newsletter subscriptions gem 'pg' diff --git a/Gemfile.lock b/Gemfile.lock index d9061044b..e91656445 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -211,6 +211,11 @@ GEM ffi (1.17.4) ffi (1.17.4-x86_64-linux-gnu) flickraw (0.9.10) + flipper (1.4.2) + concurrent-ruby (< 2) + flipper-active_record (1.4.2) + activerecord (>= 4.2, < 9) + flipper (~> 1.4.2) foreman (0.90.0) thor (~> 1.4) friendly_id (5.7.0) @@ -663,6 +668,7 @@ DEPENDENCIES faker faraday flickraw + flipper-active_record foreman friendly_id gbifrb diff --git a/app/controllers/admin/features_controller.rb b/app/controllers/admin/features_controller.rb new file mode 100644 index 000000000..18360a59c --- /dev/null +++ b/app/controllers/admin/features_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + # The features admins can switch on and off for everyone. + class FeaturesController < ApplicationController + before_action :authenticate_member! + before_action { authorize! :manage, :all } + + def index + @features = Feature.all + end + + def update + feature = Feature.find(params[:id]) + feature.enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled]) + redirect_to admin_features_path, notice: t(".#{feature.enabled? ? 'on' : 'off'}", feature: t("admin.features.names.#{feature.name}")) + end + end +end diff --git a/app/controllers/job_ideas_controller.rb b/app/controllers/job_ideas_controller.rb index 2901ef781..f7c561e7a 100644 --- a/app/controllers/job_ideas_controller.rb +++ b/app/controllers/job_ideas_controller.rb @@ -4,9 +4,10 @@ # their jobs, snooze it, or say it is not for them. On the page, or as an overlay # on their jobs, where each choice is sent without leaving and answered with the # ideas that are left, the jobs that were added and the member's jobs as they now -# are. +# are. Only while admins have the job ideas feature switched on. class JobIdeasController < ApplicationController before_action :authenticate_member! + before_action { raise ActiveRecord::RecordNotFound unless Feature.enabled?(:job_ideas) } def index load_ideas diff --git a/app/models/feature.rb b/app/models/feature.rb new file mode 100644 index 000000000..939c85b8e --- /dev/null +++ b/app/models/feature.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# The features admins can switch on and off, kept in Flipper. Each is off until +# an admin switches it on. +class Feature + NAMES = %i(job_ideas).freeze + + attr_reader :name + + def self.all + NAMES.map { |name| new(name) } + end + + def self.find(name) + feature = NAMES.find { |known| known.to_s == name.to_s } + raise ActiveRecord::RecordNotFound unless feature + + new(feature) + end + + def self.enabled?(name) + Flipper.enabled?(name) + end + + def initialize(name) + @name = name + end + + def enabled? + Flipper.enabled?(name) + end + + def enabled=(on) + on ? Flipper.enable(name) : Flipper.disable(name) + end + + def to_param + name.to_s + end +end diff --git a/app/views/admin/features/index.html.haml b/app/views/admin/features/index.html.haml new file mode 100644 index 000000000..9ed98d7f8 --- /dev/null +++ b/app/views/admin/features/index.html.haml @@ -0,0 +1,31 @@ +- content_for :title, t('.title') + +- content_for :breadcrumbs do + %li.breadcrumb-item= link_to 'Admin', admin_path + %li.breadcrumb-item.active= link_to t('.title'), admin_features_path + +%p= t('.intro') + +%table.table.table-striped + %thead + %tr + %th= t('.feature') + %th= t('.state') + %th + %tbody + - @features.each do |feature| + %tr{ id: "feature-#{feature.name}" } + %td + %strong= t("admin.features.names.#{feature.name}") + %br + %span.text-muted= t("admin.features.descriptions.#{feature.name}") + %td + - if feature.enabled? + %span.badge.bg-success= t('.on') + - else + %span.badge.bg-secondary= t('.off') + %td + - on = feature.enabled? + = button_to t(on ? '.switch_off' : '.switch_on'), admin_feature_path(feature), + method: :patch, params: { enabled: !on }, + class: ['btn btn-sm', on ? 'btn-outline-secondary' : 'btn-primary'] diff --git a/app/views/admin/index.html.haml b/app/views/admin/index.html.haml index 447d52f31..baeb41089 100644 --- a/app/views/admin/index.html.haml +++ b/app/views/admin/index.html.haml @@ -15,6 +15,7 @@ .card-body %nav.nav#site_admin %li= link_to "Roles", admin_roles_path, class: 'nav-link' + %li= link_to t('admin.features.index.title'), admin_features_path, class: 'nav-link' %li= link_to "Forums", forums_path, class: 'nav-link' %li= link_to t('.garden_types'), garden_types_path, class: 'nav-link' diff --git a/app/views/home/dashboard/_ideas.html.haml b/app/views/home/dashboard/_ideas.html.haml index b673e452d..3ce646cf5 100644 --- a/app/views/home/dashboard/_ideas.html.haml +++ b/app/views/home/dashboard/_ideas.html.haml @@ -1,6 +1,6 @@ -# A yellow bar over the front page when there are job ideas to look at. It leads to --# their jobs, with the ideas showing. -- count = JobIdeas.count_for(current_member) +-# their jobs, with the ideas showing. Only while the job ideas feature is on. +- count = Feature.enabled?(:job_ideas) ? JobIdeas.count_for(current_member) : 0 - if count.positive? .home-ideas = render 'jobs/ideas_banner', count: count, href: member_jobs_path(current_member, ideas: 1), open: false diff --git a/app/views/jobs/_ideas.html.haml b/app/views/jobs/_ideas.html.haml index a88388f7f..de155b1f8 100644 --- a/app/views/jobs/_ideas.html.haml +++ b/app/views/jobs/_ideas.html.haml @@ -1,5 +1,6 @@ -# The ideas over a member's jobs: the yellow box that says there are some, if there --# are, and the overlay it opens. -- count = JobIdeas.count_for(owner) -= render 'jobs/ideas_banner', count: count if count.positive? -= render 'jobs/ideas_modal' +-# are, and the overlay it opens. Only while the job ideas feature is on. +- if Feature.enabled?(:job_ideas) + - count = JobIdeas.count_for(owner) + = render 'jobs/ideas_banner', count: count if count.positive? + = render 'jobs/ideas_modal' diff --git a/config/locales/en.yml b/config/locales/en.yml index 478f401e0..7008019dd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -123,6 +123,24 @@ en: heirloom_inclusion: "You must say whether the seeds are heirloom, hybrid, or unknown" source: source_inclusion: "You must say where the seeds are from, or that you don't know" + admin: + features: + descriptions: + job_ideas: Suggested jobs for members, from their past jobs and plantings, crop templates and what gardeners nearby are doing. + index: + feature: Feature + intro: Switch features on or off for everyone. + 'off': 'Off' + 'on': 'On' + state: State + switch_off: Switch off + switch_on: Switch on + title: Features + names: + job_ideas: Job ideas + update: + 'off': "%{feature} is now off." + 'on': "%{feature} is now on." application_helper: title: title: diff --git a/config/routes.rb b/config/routes.rb index 7f95fdeaf..b3f972969 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -190,6 +190,7 @@ Rails.application.routes.draw do post :restore, on: :member end resources :roles + resources :features, only: %i(index update) resources :crops, param: :slug do resources :crop_companions resources :crop_job_templates, path: 'job_templates', except: :show diff --git a/db/migrate/20261024031447_create_flipper_tables.rb b/db/migrate/20261024031447_create_flipper_tables.rb new file mode 100644 index 000000000..21a6a45f1 --- /dev/null +++ b/db/migrate/20261024031447_create_flipper_tables.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Flipper's tables, for the features admins switch on and off. +class CreateFlipperTables < ActiveRecord::Migration[8.1] + def up + create_table :flipper_features do |t| + t.string :key, null: false + t.timestamps null: false + end + add_index :flipper_features, :key, unique: true + + create_table :flipper_gates do |t| + t.string :feature_key, null: false + t.string :key, null: false + t.text :value + t.timestamps null: false + end + add_index :flipper_gates, %i(feature_key key value), unique: true, length: { value: 255 } + end + + def down + drop_table :flipper_gates + drop_table :flipper_features + end +end diff --git a/db/schema.rb b/db/schema.rb index f15487dd9..1495adfe2 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_23_050000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_24_031447) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -602,6 +602,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_23_050000) do t.index ["slug"], name: "index_crops_on_slug", unique: true end + create_table "flipper_features", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_flipper_features_on_key", unique: true + end + + create_table "flipper_gates", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "feature_key", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.text "value" + t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true + end + create_table "follows", id: :serial, force: :cascade do |t| t.datetime "created_at", precision: nil t.integer "followed_id" diff --git a/spec/requests/admin/features_controller_spec.rb b/spec/requests/admin/features_controller_spec.rb new file mode 100644 index 000000000..98a42fd70 --- /dev/null +++ b/spec/requests/admin/features_controller_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::FeaturesController do + let(:admin) { create(:admin_member) } + let(:page) { Capybara.string(response.body) } + + describe '#index' do + context 'with an admin' do + before do + sign_in admin + get admin_features_path + end + + it 'lists job ideas' do + expect(page).to have_css('#feature-job_ideas', text: 'Job ideas') + end + + it 'shows it is off' do + expect(page).to have_css('#feature-job_ideas .badge', text: 'Off') + end + + it 'offers to switch it on' do + expect(page).to have_button('Switch on') + end + end + + context 'with job ideas switched on', flipper: :job_ideas do + before do + sign_in admin + get admin_features_path + end + + it 'offers to switch it off' do + expect(page).to have_button('Switch off') + end + end + + context 'with a member who is not an admin' do + before do + sign_in create(:member) + get admin_features_path + end + + it 'turns them away' do + expect(response).to redirect_to(root_path) + end + end + + context 'when signed out' do + before { get admin_features_path } + + it 'sends them to sign in' do + expect(response).to redirect_to(new_member_session_path) + end + end + end + + describe '#update' do + before { sign_in admin } + + context 'when switching on' do + before { patch admin_feature_path('job_ideas'), params: { enabled: true } } + + it 'switches it on' do + expect(Flipper).to be_enabled(:job_ideas) + end + + it 'goes back to the features' do + expect(response).to redirect_to(admin_features_path) + end + + it 'says so' do + expect(flash[:notice]).to eq 'Job ideas is now on.' + end + end + + context 'when switching off', flipper: :job_ideas do + before { patch admin_feature_path('job_ideas'), params: { enabled: false } } + + it 'switches it off' do + expect(Flipper).not_to be_enabled(:job_ideas) + end + end + + context 'with a feature that is not on the list' do + before { patch admin_feature_path('anything_else'), params: { enabled: true } } + + it 'is not found' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with a member who is not an admin' do + before do + sign_in create(:member) + patch admin_feature_path('job_ideas'), params: { enabled: true } + end + + it 'leaves it off' do + expect(Flipper).not_to be_enabled(:job_ideas) + end + end + end +end diff --git a/spec/requests/home_controller_index_with_job_ideas_spec.rb b/spec/requests/home_controller_index_with_job_ideas_spec.rb index 56ac42582..6a00c1b49 100644 --- a/spec/requests/home_controller_index_with_job_ideas_spec.rb +++ b/spec/requests/home_controller_index_with_job_ideas_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe HomeController, '#index with job ideas' do +describe HomeController, '#index with job ideas', flipper: :job_ideas do let(:member) { create(:member) } let(:page) { Capybara.string(response.body) } @@ -30,6 +30,17 @@ describe HomeController, '#index with job ideas' do end end + context 'when job ideas are switched off', flipper: [] do + before do + create(:job, :finished_without_time, owner: member, name: 'Snip off scapes', due_date: Date.current - 1.year + 2.days) + get root_path + end + + it 'has no yellow bar' do + expect(page).to have_no_css('.notepad-ideas') + end + end + context 'when there are none' do before { get root_path } diff --git a/spec/requests/job_ideas_controller_spec.rb b/spec/requests/job_ideas_controller_spec.rb index 8f40b626d..9d5df9595 100644 --- a/spec/requests/job_ideas_controller_spec.rb +++ b/spec/requests/job_ideas_controller_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe JobIdeasController do +describe JobIdeasController, flipper: :job_ideas do include ActiveSupport::Testing::TimeHelpers let(:member) { create(:member) } @@ -144,6 +144,14 @@ describe JobIdeasController do end end + describe 'GET /job_ideas when job ideas are switched off', flipper: [] do + before { get job_ideas_path } + + it 'is not found' do + expect(response).to have_http_status(:not_found) + end + end + describe 'POST /job_ideas' do let(:key) { "past_job:snip off scapes:#{crop.id}" } diff --git a/spec/requests/jobs_controller_index_with_ideas_spec.rb b/spec/requests/jobs_controller_index_with_ideas_spec.rb index ce78c4bca..62267e8f5 100644 --- a/spec/requests/jobs_controller_index_with_ideas_spec.rb +++ b/spec/requests/jobs_controller_index_with_ideas_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe JobsController, '#index with ideas' do +describe JobsController, '#index with ideas', flipper: :job_ideas do let(:member) { create(:member) } let(:page) { Capybara.string(response.body) } @@ -30,6 +30,21 @@ describe JobsController, '#index with ideas' do end end + context 'when job ideas are switched off', flipper: [] do + before do + create(:job, :finished_without_time, owner: member, name: 'Snip off scapes', due_date: Date.current - 1.year + 2.days) + get member_jobs_path(member) + end + + it 'shows no yellow box' do + expect(page).to have_no_css('.notepad-ideas') + end + + it 'has no overlay' do + expect(page).to have_no_css('#job-ideas-modal', visible: :all) + end + end + context 'when there are none' do before { get member_jobs_path(member) } diff --git a/spec/support/flipper.rb b/spec/support/flipper.rb new file mode 100644 index 000000000..543855075 --- /dev/null +++ b/spec/support/flipper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Features are kept in memory in specs, and all off at the start of each. Tag an +# example group with `flipper: :name` (or a list of names) to have them on. +require 'flipper/test_help' + +RSpec.configure do |config| + config.before do |example| + Array(example.metadata[:flipper]).each { |name| Flipper.enable(name) } + end +end From bd4a55331ef880e8e7e20dafd05332165d9feaac Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 18:05:47 +1300 Subject: [PATCH 13/30] admin screen for crop job templates --- .../admin/job_templates_controller.rb | 17 +++++ app/views/admin/index.html.haml | 1 + app/views/admin/job_templates/index.html.haml | 42 +++++++++++ config/routes.rb | 1 + .../admin/job_templates_controller_spec.rb | 75 +++++++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 app/controllers/admin/job_templates_controller.rb create mode 100644 app/views/admin/job_templates/index.html.haml create mode 100644 spec/requests/admin/job_templates_controller_spec.rb diff --git a/app/controllers/admin/job_templates_controller.rb b/app/controllers/admin/job_templates_controller.rb new file mode 100644 index 000000000..ba6085581 --- /dev/null +++ b/app/controllers/admin/job_templates_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Admin + # Every crop's job templates in one list, for crop wranglers to find the + # drafts waiting to be checked and published. + class JobTemplatesController < ApplicationController + before_action :authenticate_member! + before_action { authorize! :wrangle, Crop } + + def index + @status = params[:status] if CropJobTemplate.statuses.key?(params[:status]) + @templates = CropJobTemplate.joins(:crop).includes(:steps, crop: :parent).order('crops.name', :name) + @templates = @templates.where(status: @status) if @status + @templates = @templates.paginate(page: params[:page]) + end + end +end diff --git a/app/views/admin/index.html.haml b/app/views/admin/index.html.haml index baeb41089..a6e37c227 100644 --- a/app/views/admin/index.html.haml +++ b/app/views/admin/index.html.haml @@ -31,6 +31,7 @@ %li= link_to "Alternate names", alternate_names_path, class: 'nav-link' %li= link_to "Scientific names", scientific_names_path, class: 'nav-link' %li= link_to "Plant parts", plant_parts_path, class: 'nav-link' + %li= link_to "Crop job templates", admin_job_templates_path, class: 'nav-link' .col-md-4 .card diff --git a/app/views/admin/job_templates/index.html.haml b/app/views/admin/job_templates/index.html.haml new file mode 100644 index 000000000..0b3c9fbc8 --- /dev/null +++ b/app/views/admin/job_templates/index.html.haml @@ -0,0 +1,42 @@ +- content_for :title, 'Crop job templates' + +- content_for :breadcrumbs do + %li.breadcrumb-item= link_to 'Admin', admin_path + %li.breadcrumb-item.active Crop job templates + +%h1 Crop job templates +%p.text-muted + Every crop's job templates. Gardeners are only offered the published ones, so drafts are waiting for a crop + wrangler to check them. + +%ul.nav.nav-tabs.mb-3 + %li.nav-item + = link_to 'All', admin_job_templates_path, class: "nav-link #{'active' if @status.nil?}" + - CropJobTemplate.statuses.each_key do |status| + %li.nav-item + = link_to crop_job_option(:status, status), admin_job_templates_path(status:), + class: "nav-link #{'active' if @status == status}" + +- if @templates.empty? + %p No job templates here. +- else + %table.table + %thead + %tr + %th Crop + %th Name + %th Season + %th Steps + %th Status + %tbody + - @templates.each do |template| + - crop = template.crop.parent || template.crop + %tr + %td= link_to template.crop.name, admin_crop_crop_job_templates_path(crop) + %td= link_to template.name, edit_admin_crop_crop_job_template_path(crop, template) + %td= crop_job_option(:season, template.season) + %td= template.steps.size + %td + - badge = template.published? ? 'text-bg-success' : 'text-bg-secondary' + %span.badge{ class: badge }= crop_job_option(:status, template.status) + = will_paginate @templates diff --git a/config/routes.rb b/config/routes.rb index b3f972969..8a693671b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -191,6 +191,7 @@ Rails.application.routes.draw do end resources :roles resources :features, only: %i(index update) + resources :job_templates, only: :index resources :crops, param: :slug do resources :crop_companions resources :crop_job_templates, path: 'job_templates', except: :show diff --git a/spec/requests/admin/job_templates_controller_spec.rb b/spec/requests/admin/job_templates_controller_spec.rb new file mode 100644 index 000000000..38cf829f1 --- /dev/null +++ b/spec/requests/admin/job_templates_controller_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::JobTemplatesController do + let(:tomato) { create(:crop, name: 'tomato') } + let(:cherry) { create(:crop, name: 'cherry tomato', parent: tomato) } + + before do + create(:crop_job_template, crop: tomato, name: 'Growing tomatoes', status: 'published') + create(:crop_job_template, crop: cherry, name: 'Growing cherry tomatoes') + create(:crop_job_template, name: 'Growing something else') + end + + describe '#index' do + context 'with a crop wrangler' do + before { sign_in create(:crop_wrangling_member) } + + context 'without a status' do + before { get admin_job_templates_path } + + it 'lists every crop\'s templates' do + expect(response.body).to include('Growing tomatoes', 'Growing cherry tomatoes', 'Growing something else') + end + + it 'links a variety\'s template to its parent crop\'s templates' do + expect(response.body).to include(edit_admin_crop_crop_job_template_path(tomato, cherry.crop_job_templates.first)) + end + end + + context 'when filtered to published' do + before { get admin_job_templates_path(status: 'published') } + + it 'lists the published templates' do + expect(response.body).to include('Growing tomatoes') + end + + it 'leaves out the drafts' do + expect(response.body).not_to include('Growing cherry tomatoes') + end + end + + context 'when filtered to draft' do + before { get admin_job_templates_path(status: 'draft') } + + it 'lists the drafts' do + expect(response.body).to include('Growing cherry tomatoes', 'Growing something else') + end + + it 'leaves out the published templates' do + expect(response.body).not_to include('Growing tomatoes') + end + end + + context 'with an unknown status' do + before { get admin_job_templates_path(status: 'nonsense') } + + it 'lists every template' do + expect(response.body).to include('Growing tomatoes', 'Growing cherry tomatoes') + end + end + end + + context 'when not a crop wrangler' do + before do + sign_in create(:member) + get admin_job_templates_path + end + + it 'is turned away' do + expect(response).to redirect_to(root_path) + end + end + end +end From f2e437dedc52d7dfba44bb6ff82e120b73b5f3ed Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 18:06:39 +1300 Subject: [PATCH 14/30] Add creator reference to crop job templates migration --- app/views/admin/job_templates/index.html.haml | 2 +- .../20261024100000_add_creator_to_crop_job_templates.rb | 8 ++++++++ db/schema.rb | 5 ++++- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20261024100000_add_creator_to_crop_job_templates.rb diff --git a/app/views/admin/job_templates/index.html.haml b/app/views/admin/job_templates/index.html.haml index 0b3c9fbc8..8d7bee11b 100644 --- a/app/views/admin/job_templates/index.html.haml +++ b/app/views/admin/job_templates/index.html.haml @@ -15,7 +15,7 @@ - CropJobTemplate.statuses.each_key do |status| %li.nav-item = link_to crop_job_option(:status, status), admin_job_templates_path(status:), - class: "nav-link #{'active' if @status == status}" + class: "nav-link #{'active' if @status == status}" - if @templates.empty? %p No job templates here. diff --git a/db/migrate/20261024100000_add_creator_to_crop_job_templates.rb b/db/migrate/20261024100000_add_creator_to_crop_job_templates.rb new file mode 100644 index 000000000..838f78432 --- /dev/null +++ b/db/migrate/20261024100000_add_creator_to_crop_job_templates.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Who wrote a crop job template. Templates loaded from the seed files have none. +class AddCreatorToCropJobTemplates < ActiveRecord::Migration[8.1] + def change + add_reference :crop_job_templates, :creator, foreign_key: { to_table: :members } + end +end diff --git a/db/schema.rb b/db/schema.rb index 1495adfe2..fba27bc35 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_24_031447) do +ActiveRecord::Schema[8.1].define(version: 2026_10_24_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -550,6 +550,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_031447) do t.string "season", default: "any", null: false t.string "status", default: "draft", null: false t.datetime "updated_at", null: false + t.bigint "creator_id" + t.index ["creator_id"], name: "index_crop_job_templates_on_creator_id" t.index ["crop_id"], name: "index_crop_job_templates_on_crop_id" end @@ -1109,6 +1111,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_031447) do add_foreign_key "crop_dismissals", "members" add_foreign_key "crop_job_steps", "crop_job_templates" add_foreign_key "crop_job_templates", "crops" + add_foreign_key "crop_job_templates", "members", column: "creator_id" add_foreign_key "harvests", "plantings" add_foreign_key "job_idea_dismissals", "members" add_foreign_key "jobs", "crop_job_steps", on_delete: :nullify From c830624a75de4e633443155c156a5f3eebbef1ee Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 18:08:04 +1300 Subject: [PATCH 15/30] Crop job admin --- app/controllers/admin/crop_job_templates_controller.rb | 4 ++-- app/controllers/admin/job_templates_controller.rb | 2 +- app/models/crop_job_template.rb | 4 +++- app/models/member.rb | 2 ++ app/views/admin/crop_job_templates/index.html.haml | 2 ++ app/views/admin/job_templates/index.html.haml | 2 ++ db/schema.rb | 2 +- spec/requests/admin/crop_job_templates_controller_spec.rb | 4 ++++ spec/requests/admin/job_templates_controller_spec.rb | 7 ++++++- 9 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/controllers/admin/crop_job_templates_controller.rb b/app/controllers/admin/crop_job_templates_controller.rb index 22fabd318..4006cbbf4 100644 --- a/app/controllers/admin/crop_job_templates_controller.rb +++ b/app/controllers/admin/crop_job_templates_controller.rb @@ -10,7 +10,7 @@ module Admin before_action :set_template, only: %i(edit update destroy) def index - @templates = CropJobTemplate.where(crop_id: crop_ids).includes(:crop, :steps).order(:name) + @templates = CropJobTemplate.where(crop_id: crop_ids).includes(:crop, :creator, :steps).order(:name) end def new @@ -24,7 +24,7 @@ module Admin end def create - @template = CropJobTemplate.new(template_params) + @template = CropJobTemplate.new(template_params.merge(creator: current_member)) return render_with_another_step(:new) if add_step? if @template.save diff --git a/app/controllers/admin/job_templates_controller.rb b/app/controllers/admin/job_templates_controller.rb index ba6085581..462036479 100644 --- a/app/controllers/admin/job_templates_controller.rb +++ b/app/controllers/admin/job_templates_controller.rb @@ -9,7 +9,7 @@ module Admin def index @status = params[:status] if CropJobTemplate.statuses.key?(params[:status]) - @templates = CropJobTemplate.joins(:crop).includes(:steps, crop: :parent).order('crops.name', :name) + @templates = CropJobTemplate.joins(:crop).includes(:creator, :steps, crop: :parent).order('crops.name', :name) @templates = @templates.where(status: @status) if @status @templates = @templates.paginate(page: params[:page]) end diff --git a/app/models/crop_job_template.rb b/app/models/crop_job_template.rb index 126d81909..e842a401f 100644 --- a/app/models/crop_job_template.rb +++ b/app/models/crop_job_template.rb @@ -2,9 +2,11 @@ # A crop's suggested jobs, offered to gardeners who plant it. It belongs to the # crop it is written for and applies to that crop's varieties too. Only -# published templates are offered. +# published templates are offered. Its creator is the member who wrote it; +# templates loaded from the seed files have none. class CropJobTemplate < ApplicationRecord belongs_to :crop + belongs_to :creator, class_name: 'Member', optional: true, inverse_of: :created_crop_job_templates has_many :steps, -> { order(:position, :id) }, class_name: 'CropJobStep', dependent: :destroy, inverse_of: :crop_job_template # Which months a season is depends on the gardener's hemisphere, so it is diff --git a/app/models/member.rb b/app/models/member.rb index 238e19e67..7e98211f7 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -81,6 +81,8 @@ class Member < ApplicationRecord inverse_of: :creator has_many :created_alternate_names, class_name: 'AlternateName', foreign_key: 'creator_id', inverse_of: :creator has_many :created_scientific_names, class_name: 'ScientificName', foreign_key: 'creator_id', inverse_of: :creator + has_many :created_crop_job_templates, class_name: 'CropJobTemplate', foreign_key: 'creator_id', dependent: :nullify, + inverse_of: :creator # # Scopes diff --git a/app/views/admin/crop_job_templates/index.html.haml b/app/views/admin/crop_job_templates/index.html.haml index b18536ab8..2a47c4a6d 100644 --- a/app/views/admin/crop_job_templates/index.html.haml +++ b/app/views/admin/crop_job_templates/index.html.haml @@ -22,6 +22,7 @@ %th Name %th Applies to %th Season + %th Written by %th Steps %th Status %th @@ -31,6 +32,7 @@ %td= link_to template.name, edit_admin_crop_crop_job_template_path(@crop, template) %td= template.crop == @crop ? t('crop_job_templates.options.applies_to_all', crop: @crop.name) : t('crop_job_templates.options.applies_to_one', crop: template.crop.name) %td= crop_job_option(:season, template.season) + %td= template.creator ? link_to(template.creator, template.creator) : 'Seed data' %td= template.steps.size %td %span.badge{ class: template.published? ? 'text-bg-success' : 'text-bg-secondary' }= crop_job_option(:status, template.status) diff --git a/app/views/admin/job_templates/index.html.haml b/app/views/admin/job_templates/index.html.haml index 8d7bee11b..070484670 100644 --- a/app/views/admin/job_templates/index.html.haml +++ b/app/views/admin/job_templates/index.html.haml @@ -26,6 +26,7 @@ %th Crop %th Name %th Season + %th Written by %th Steps %th Status %tbody @@ -35,6 +36,7 @@ %td= link_to template.crop.name, admin_crop_crop_job_templates_path(crop) %td= link_to template.name, edit_admin_crop_crop_job_template_path(crop, template) %td= crop_job_option(:season, template.season) + %td= template.creator ? link_to(template.creator, template.creator) : 'Seed data' %td= template.steps.size %td - badge = template.published? ? 'text-bg-success' : 'text-bg-secondary' diff --git a/db/schema.rb b/db/schema.rb index fba27bc35..521a68788 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -545,12 +545,12 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_100000) do create_table "crop_job_templates", force: :cascade do |t| t.datetime "created_at", null: false + t.bigint "creator_id" t.bigint "crop_id", null: false t.string "name", null: false t.string "season", default: "any", null: false t.string "status", default: "draft", null: false t.datetime "updated_at", null: false - t.bigint "creator_id" t.index ["creator_id"], name: "index_crop_job_templates_on_creator_id" t.index ["crop_id"], name: "index_crop_job_templates_on_crop_id" end diff --git a/spec/requests/admin/crop_job_templates_controller_spec.rb b/spec/requests/admin/crop_job_templates_controller_spec.rb index 81d1e9a1a..785391beb 100644 --- a/spec/requests/admin/crop_job_templates_controller_spec.rb +++ b/spec/requests/admin/crop_job_templates_controller_spec.rb @@ -57,6 +57,10 @@ describe Admin::CropJobTemplatesController do expect(crop.crop_job_templates.first).to be_draft end + it 'records who wrote it' do + expect(crop.crop_job_templates.first.creator).to eq wrangler + end + it 'goes back to the list' do expect(response).to redirect_to(admin_crop_crop_job_templates_path(crop)) end diff --git a/spec/requests/admin/job_templates_controller_spec.rb b/spec/requests/admin/job_templates_controller_spec.rb index 38cf829f1..a87906f0c 100644 --- a/spec/requests/admin/job_templates_controller_spec.rb +++ b/spec/requests/admin/job_templates_controller_spec.rb @@ -5,9 +5,10 @@ require 'rails_helper' describe Admin::JobTemplatesController do let(:tomato) { create(:crop, name: 'tomato') } let(:cherry) { create(:crop, name: 'cherry tomato', parent: tomato) } + let(:author) { create(:member, login_name: 'template_author') } before do - create(:crop_job_template, crop: tomato, name: 'Growing tomatoes', status: 'published') + create(:crop_job_template, crop: tomato, name: 'Growing tomatoes', status: 'published', creator: author) create(:crop_job_template, crop: cherry, name: 'Growing cherry tomatoes') create(:crop_job_template, name: 'Growing something else') end @@ -23,6 +24,10 @@ describe Admin::JobTemplatesController do expect(response.body).to include('Growing tomatoes', 'Growing cherry tomatoes', 'Growing something else') end + it 'shows who wrote a template' do + expect(response.body).to include('template_author') + end + it 'links a variety\'s template to its parent crop\'s templates' do expect(response.body).to include(edit_admin_crop_crop_job_template_path(tomato, cherry.crop_job_templates.first)) end From 7a9fa0de11fffadff2f3c03bf9ac19c389d0f3d8 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 18:11:09 +1300 Subject: [PATCH 16/30] Add job-templates-table class to job templates tables for styling consistency --- app/assets/stylesheets/_crops.scss | 6 ++++-- app/views/admin/crop_job_templates/index.html.haml | 2 +- app/views/admin/job_templates/index.html.haml | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/_crops.scss b/app/assets/stylesheets/_crops.scss index f90c2df0d..493268079 100644 --- a/app/assets/stylesheets/_crops.scss +++ b/app/assets/stylesheets/_crops.scss @@ -206,7 +206,9 @@ font-size: 0.85rem; } -// The crop wrangling table, on white so it reads against the page. -.wrangle-table { +// The crop wrangling and job template tables, on white so they read against the +// page. +.wrangle-table, +.job-templates-table { --bs-table-bg: #{$white}; } diff --git a/app/views/admin/crop_job_templates/index.html.haml b/app/views/admin/crop_job_templates/index.html.haml index 2a47c4a6d..1b7597537 100644 --- a/app/views/admin/crop_job_templates/index.html.haml +++ b/app/views/admin/crop_job_templates/index.html.haml @@ -16,7 +16,7 @@ - if @templates.empty? %p= "No job templates for #{@crop.name} yet." - else - %table.table + %table.table.job-templates-table %thead %tr %th Name diff --git a/app/views/admin/job_templates/index.html.haml b/app/views/admin/job_templates/index.html.haml index 070484670..052c685c1 100644 --- a/app/views/admin/job_templates/index.html.haml +++ b/app/views/admin/job_templates/index.html.haml @@ -20,7 +20,7 @@ - if @templates.empty? %p No job templates here. - else - %table.table + %table.table.job-templates-table %thead %tr %th Crop From e711ae9e9a9af514e87599a19db8d8faa257c691 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 24 Oct 2026 18:12:50 +1300 Subject: [PATCH 17/30] Add admin and crops current section tracking to menu and update header specs for crop wranglers --- app/views/layouts/_menu.haml | 6 ++++-- spec/views/layouts/_header_spec.rb | 31 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/_menu.haml b/app/views/layouts/_menu.haml index f3d2a20a4..10cf53585 100644 --- a/app/views/layouts/_menu.haml +++ b/app/views/layouts/_menu.haml @@ -1,5 +1,7 @@ - my_garden_current = member_signed_in? && current_section?(member_gardens_path(current_member)) - my_pages_current = member_signed_in? && current_section?(member_path(current_member)) +- admin_current = current_section?(admin_path, wrangle_crops_path) +- crops_current = !admin_current && current_section?(crops_path) - community_current = !my_garden_current && !my_pages_current && current_section?(places_path, gardens_path, harvests_path, seeds_path, plantings_path, members_path, community_gardens_path, posts_path, forums_path, releases_path) #navbarSupportedContent.offcanvas-lg.offcanvas-end{ tabindex: -1, 'aria-label' => 'Menu' } -# The header and the section headings are only for the drawer a phone or tablet gets. @@ -45,7 +47,7 @@ - if neighbours_new? %span.badge.text-bg-success.ms-1= t('.new_badge') %li.nav-item - = link_to crops_path, class: 'nav-link', aria: { current: ('page' if current_section?(crops_path)) } do + = link_to crops_path, class: 'nav-link', aria: { current: ('page' if crops_current) } do = menu_icon(:crop) = t('.crops') %li.nav-item.dropdown @@ -92,7 +94,7 @@ = t('.help_and_community') - if member_signed_in? && (current_member.role?(:crop_wrangler) || current_member.role?(:admin)) %li.nav-item.dropdown - %a.nav-link.dropdown-toggle{"aria-expanded" => "false", "aria-haspopup" => "true", "data-bs-toggle" => "dropdown", href: "#", role: "button"} + %a.nav-link.dropdown-toggle{"aria-expanded" => "false", "aria-haspopup" => "true", "data-bs-toggle" => "dropdown", href: "#", role: "button", "aria-current" => ('page' if admin_current)} %span = t('.admin') .dropdown-menu{"aria-labelledby" => "navbarDropdown"} diff --git a/spec/views/layouts/_header_spec.rb b/spec/views/layouts/_header_spec.rb index 4cdad8e53..ce18ca21c 100644 --- a/spec/views/layouts/_header_spec.rb +++ b/spec/views/layouts/_header_spec.rb @@ -133,6 +133,37 @@ describe 'layouts/_header.html.haml', type: "view" do end end + context 'when logged in as a crop wrangler' do + let(:wrangler) { create(:crop_wrangling_member) } + + before do + sign_in wrangler + controller.stub(:current_user) { wrangler } + end + + context 'when in the admin section' do + before do + allow(view.request).to receive(:path).and_return('/admin/job_templates') + render + end + + it 'marks Admin' do + assert_select("a.dropdown-toggle[aria-current='page']", text: /Admin/) + end + end + + context 'when wrangling crops' do + before do + allow(view.request).to receive(:path).and_return('/crops/wrangle') + render + end + + it 'marks only Admin' do + assert_select("a[aria-current='page']", count: 1, text: /Admin/) + end + end + end + context 'logged in, has notifications' do before do @member = create(:member) From d46ade47227e2fd59810fad62debe3c35c75dd20 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sat, 24 Oct 2026 16:26:47 +1030 Subject: [PATCH 18/30] Replace deprecated SimpleCov.add_filter with SimpleCov.skip (#5068) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- spec/spec_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 478af2b5f..9719cba37 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -24,7 +24,7 @@ if ENV['CI'] || ENV['COVERAGE'] SimpleCov.command_name "rspec-#{Process.pid}" SimpleCov.start 'rails' do enable_coverage :branch - add_filter %r{^/spec/} + skip %r{^/spec/} end end From d03f81d1c8e6b1e1ce5957b204e271a6b35f134f Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 11:59:24 +1300 Subject: [PATCH 19/30] Add functionality to hide completed jobs in notepad and update styles --- app/assets/stylesheets/_jobs.scss | 4 ++++ app/controllers/jobs_controller.rb | 5 +++++ app/javascript/site/job_notepad.js | 20 +++++++++++++++++--- app/services/job_list.rb | 16 +++++++--------- app/views/jobs/_notepad.html.haml | 1 + config/locales/en.yml | 1 + 6 files changed, 35 insertions(+), 12 deletions(-) diff --git a/app/assets/stylesheets/_jobs.scss b/app/assets/stylesheets/_jobs.scss index 518e3572e..be81ceb21 100644 --- a/app/assets/stylesheets/_jobs.scss +++ b/app/assets/stylesheets/_jobs.scss @@ -119,6 +119,10 @@ $notepad-trail: #a4c98c; min-height: 44px; } + .notepad-hide-done { + margin: 0 1rem 0.5rem; + } + .notepad-note { color: $ink-muted; font-size: 0.9rem; diff --git a/app/controllers/jobs_controller.rb b/app/controllers/jobs_controller.rb index 03a45b704..076f0431a 100644 --- a/app/controllers/jobs_controller.rb +++ b/app/controllers/jobs_controller.rb @@ -7,6 +7,7 @@ class JobsController < DataController if notepad? load_notepad + return render_groups(:ok) if request.xhr? else @jobs = listed_jobs.paginate(page: params[:page], per_page: 30) end @@ -78,6 +79,10 @@ class JobsController < DataController def render_notepad(status) @owner = current_member load_notepad + render_groups(status) + end + + def render_groups(status) response.set_header('X-Jobs-Done-This-Week', @done_this_week.to_s) render partial: 'jobs/notepad_groups', locals: { groups: @job_groups, in_garden: garden.present? }, status: end diff --git a/app/javascript/site/job_notepad.js b/app/javascript/site/job_notepad.js index 5953d2ec7..02c780352 100644 --- a/app/javascript/site/job_notepad.js +++ b/app/javascript/site/job_notepad.js @@ -37,6 +37,10 @@ document.addEventListener('DOMContentLoaded', function () { // move to Done when the page is loaded again. const kept = new Set(); + // Hide done moves the jobs crossed off since the page loaded down to Done, + // as loading the page again would. It is only there when there are some. + const hideDoneButton = document.getElementById('notepad-hide-done'); + // The tick box or menu button with focus in the list, as the job it belongs to // and which control it is, so its replacement can be focused when the list is // drawn again. @@ -50,10 +54,12 @@ document.addEventListener('DOMContentLoaded', function () { // Sends a change to the server and shows the groups it sends back. Resolves // with how many jobs are done this week. const transmit = function (url, body) { - kept.forEach(function (id) { body.append('keep[]', id); }); - if (groups.dataset.gardenId) body.append('garden_id', groups.dataset.gardenId); + if (body) { + kept.forEach(function (id) { body.append('keep[]', id); }); + if (groups.dataset.gardenId) body.append('garden_id', groups.dataset.gardenId); + } return fetch(url, { - method: 'POST', + method: body ? 'POST' : 'GET', body: body, headers: { 'X-Requested-With': 'XMLHttpRequest', @@ -67,6 +73,7 @@ document.addEventListener('DOMContentLoaded', function () { return response.text().then(function (html) { const focused = focusedControl(); groups.innerHTML = html; + if (hideDoneButton) hideDoneButton.hidden = kept.size === 0; showDone(Number(done)); const again = focused && groups.querySelector('.notepad-job[data-id="' + focused.id + '"] ' + focused.selector); if (again) again.focus(); @@ -92,6 +99,13 @@ document.addEventListener('DOMContentLoaded', function () { return send(url, body); }; + if (hideDoneButton) hideDoneButton.addEventListener('click', function () { + kept.clear(); + const url = new URL(hideDoneButton.dataset.url, window.location.origin); + if (groups.dataset.gardenId) url.searchParams.set('garden_id', groups.dataset.gardenId); + send(url.toString(), null).catch(reload); + }); + // ---- Crossing a job off, or putting it back let streak = 0; diff --git a/app/services/job_list.rb b/app/services/job_list.rb index 29232f0ed..8c601ca37 100644 --- a/app/services/job_list.rb +++ b/app/services/job_list.rb @@ -4,10 +4,9 @@ # this weekend, next weekend, later, some day, and done. All but overdue and done # are always there, as places to drop a job; each says which day a job dropped in # it gets. Overdue and done have no day of their own, so nothing is dropped there. A job -# crossed off stays where its day puts it, unless it has no day or that day has -# gone: then it moves to the bottom, to done, the one finished last first. Jobs -# crossed off in the page being looked at are pinned: they stay where they were -# until the page is loaded again, so a tick never makes a row jump. Only the +# crossed off is in done, the one finished last first, unless it was crossed off in +# the page being looked at: those are pinned, and stay where their day puts them +# until the page is loaded again, or hidden, so a tick never makes a row jump. Only the # DONE_SHOWN most recently done are shown; the rest are older, behind "show more". class JobList DONE_SHOWN = 5 @@ -48,11 +47,10 @@ class JobList end def groups - dated, undated = @jobs.partition(&:due_date) - past, current = dated.partition { |job| job.due_date < @today } - done_past, overdue = past.partition { |job| settled?(job) } - done_undated, someday = undated.partition { |job| settled?(job) } - done = (done_past + done_undated).sort_by { |job| done_time(job) }.reverse + settled, open = @jobs.partition { |job| settled?(job) } + done = settled.sort_by { |job| done_time(job) }.reverse + dated, someday = open.partition(&:due_date) + overdue, current = dated.partition { |job| job.due_date < @today } now, later = current.partition { |job| job.due_date == @today } week, later = later.partition { |job| ((@today + 1.day)..friday).cover?(job.due_date) } weekend, later = later.partition { |job| (weekend_from..week_end).cover?(job.due_date) } diff --git a/app/views/jobs/_notepad.html.haml b/app/views/jobs/_notepad.html.haml index 2dad5816b..2fcb8cd3f 100644 --- a/app/views/jobs/_notepad.html.haml +++ b/app/views/jobs/_notepad.html.haml @@ -27,6 +27,7 @@ %p.notepad-note= t('jobs.pad.public_note') + ' ' + t('jobs.pad.link_hint') = render 'jobs/ideas', owner: owner unless garden || !local_assigns.fetch(:ideas_banner, true) + %button.btn.btn-sm.btn-outline-secondary.notepad-hide-done{ id: 'notepad-hide-done', type: 'button', hidden: true, data: { url: member_jobs_path(owner) } }= t('jobs.pad.hide_done') #notepad-groups{ data: { celebrations: job_celebrations.to_json, garden_id: garden&.id } }= render 'jobs/notepad_groups', groups:, in_garden: garden.present? .notepad-snail .notepad-snail-track{ 'aria-hidden': 'true' } diff --git a/config/locales/en.yml b/config/locales/en.yml index 7008019dd..a88b16450 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -827,6 +827,7 @@ en: show_older: one: Show 1 older done job other: Show %{count} older done jobs + hide_done: Hide done add_failed: That job may not have been added. Check your list before you try again. public_note: Jobs are public, so don't put private info here. overdue: Overdue From ddd9277696c8971c15dee32b1d8cb41bff65cf7a Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 12:01:17 +1300 Subject: [PATCH 20/30] Refactor CSRF token handling in job ideas and notepad scripts; improve rendering of notepad groups --- app/javascript/site/job_ideas.js | 5 +++-- app/javascript/site/job_notepad.js | 4 ++-- app/views/jobs/_notepad_groups.html.haml | 11 ++++------- spec/services/job_list_spec.rb | 13 +++++++++++++ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/app/javascript/site/job_ideas.js b/app/javascript/site/job_ideas.js index 4af5fc15c..e7b67049d 100644 --- a/app/javascript/site/job_ideas.js +++ b/app/javascript/site/job_ideas.js @@ -4,6 +4,8 @@ // behind. Then the idea makes way and the next takes its place. If anything goes // wrong the choice is sent the ordinary way, which shows the ideas on their page. +import {csrfToken} from '../api'; + const reduced = function () { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); }; @@ -38,11 +40,10 @@ document.addEventListener('DOMContentLoaded', function () { const body = document.getElementById('job-ideas-body'); if (!modal || !body) return; - const token = document.querySelector('meta[name="csrf-token"]'); const request = function (url, options) { const settings = options || {}; return fetch(url, Object.assign({credentials: 'same-origin'}, settings, { - headers: Object.assign({'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': token ? token.content : ''}, settings.headers), + headers: Object.assign({'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrfToken()}, settings.headers), })); }; const ok = function (response) { diff --git a/app/javascript/site/job_notepad.js b/app/javascript/site/job_notepad.js index 02c780352..25c4b62e2 100644 --- a/app/javascript/site/job_notepad.js +++ b/app/javascript/site/job_notepad.js @@ -6,6 +6,7 @@ // goes wrong reloads the page, which shows how things stand. Choosing Edit in a // job's menu turns its name into a box to type in; Enter saves, Esc cancels. +import {csrfToken} from '../api'; import {celebrate, doneText, hide} from './job_celebration'; import {nextStreak, undoStreak} from './job_streak'; @@ -19,7 +20,6 @@ document.addEventListener('DOMContentLoaded', function () { const saving = document.getElementById('notepad-add-saving'); const addButton = form.querySelector('input[type="submit"]'); const addLabel = addButton.value; - const token = document.querySelector('meta[name="csrf-token"]'); const kinds = JSON.parse(form.dataset.kinds); // A suggestion's kind (planting, seed or crop) is its type, lower case. const kindOf = function (item) { return item.jobbable_type.toLowerCase(); }; @@ -63,7 +63,7 @@ document.addEventListener('DOMContentLoaded', function () { body: body, headers: { 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-Token': token ? token.content : '', + 'X-CSRF-Token': csrfToken(), }, credentials: 'same-origin', }) diff --git a/app/views/jobs/_notepad_groups.html.haml b/app/views/jobs/_notepad_groups.html.haml index 9c58e0b49..8794c6167 100644 --- a/app/views/jobs/_notepad_groups.html.haml +++ b/app/views/jobs/_notepad_groups.html.haml @@ -12,17 +12,14 @@ - if group.jobs.empty? %p.notepad-drop-hint= t('jobs.pad.drop_here') -# Later is rolled up until asked for: it is not for today's list. - - rows = capture do - - group.shown.each do |job| - = render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden] + - row_for = ->(job) { render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden] } - if group.key == :later && group.jobs.any? %details.notepad-older %summary= t('jobs.pad.show_later', count: group.jobs.size) - = rows + = safe_join(group.shown.map(&row_for)) - else - = rows + = safe_join(group.shown.map(&row_for)) - if group.older.any? %details.notepad-older %summary= t('jobs.pad.show_older', count: group.older.size) - - group.older.each do |job| - = render 'jobs/notepad_row', job:, in_garden: local_assigns[:in_garden] + = safe_join(group.older.map(&row_for)) diff --git a/spec/services/job_list_spec.rb b/spec/services/job_list_spec.rb index 8fd2dfaf7..3c39ece31 100644 --- a/spec/services/job_list_spec.rb +++ b/spec/services/job_list_spec.rb @@ -127,6 +127,19 @@ describe JobList do context 'with a crossed off job due today' do let(:due_today) { build(:job, due_date: today, finished: true) } + it 'puts it in Done' do + expect(group(:done).jobs).to eq [due_today] + end + + it 'leaves Today without it' do + expect(group(:today).jobs).to be_empty + end + end + + context 'with a crossed off job due today that is pinned' do + let(:due_today) { build_stubbed(:job, due_date: today, finished: true) } + let(:list) { described_class.new(jobs, today:, pinned: [due_today.id]) } + it 'keeps it in Today' do expect(group(:today).jobs).to eq [due_today] end From 733961b27e625004e3d74f89cb9e40b84d8d5455 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 12:02:25 +1300 Subject: [PATCH 21/30] Enhance notepad due date styling with button-like appearance and hover effects --- app/assets/stylesheets/_jobs.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/assets/stylesheets/_jobs.scss b/app/assets/stylesheets/_jobs.scss index be81ceb21..03b95a43d 100644 --- a/app/assets/stylesheets/_jobs.scss +++ b/app/assets/stylesheets/_jobs.scss @@ -405,10 +405,29 @@ $notepad-trail: #a4c98c; position: relative; margin-left: auto; + // A day you can change looks like a button: outlined, with a small arrow. .notepad-due { margin-left: 0; + padding: 0.15rem 0.5rem; + border: 1px solid currentcolor; + border-radius: 1rem; text-decoration: none; + &::after { + content: ''; + display: inline-block; + margin-left: 0.4em; + vertical-align: 0.15em; + border-top: 0.3em solid; + border-right: 0.3em solid transparent; + border-left: 0.3em solid transparent; + } + + &:hover, + &:focus-visible { + background: rgba(0, 0, 0, 0.06); + } + &.unset { color: $ink-muted; font-weight: 400; From 7dcd1ef5b31e6a115130993224b6bd15f87162f4 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 12:04:13 +1300 Subject: [PATCH 22/30] Refactor notepad menu and day styles for improved layout and alignment --- app/assets/stylesheets/_jobs.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/_jobs.scss b/app/assets/stylesheets/_jobs.scss index 03b95a43d..81c62af8e 100644 --- a/app/assets/stylesheets/_jobs.scss +++ b/app/assets/stylesheets/_jobs.scss @@ -388,9 +388,14 @@ $notepad-trail: #a4c98c; // The menu sits at the right edge. After a due label that label has already // pushed it there; without one the menu pushes itself. .notepad-menu { + display: flex; + align-items: center; margin-left: auto; .record-actions-toggle { + display: inline-flex; + align-items: center; + justify-content: center; min-width: 44px; min-height: 44px; } @@ -403,12 +408,15 @@ $notepad-trail: #a4c98c; .notepad-day { position: relative; + display: flex; + align-items: center; margin-left: auto; // A day you can change looks like a button: outlined, with a small arrow. .notepad-due { margin-left: 0; - padding: 0.15rem 0.5rem; + padding: 0.2rem 0.6rem; + line-height: 1.2; border: 1px solid currentcolor; border-radius: 1rem; text-decoration: none; From 586fcc70f96c5940e5c0e372686fdbe4d622f88b Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 13:03:20 +1300 Subject: [PATCH 23/30] Implement job permissions for members and admins in Ability model; add corresponding specs --- app/models/ability.rb | 7 +++++++ spec/models/ability_spec.rb | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/app/models/ability.rb b/app/models/ability.rb index 3bc68039f..83d0f1fc0 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -185,5 +185,12 @@ class Ability can :destroy, Member do |other_member| other_member&.id != member.id end + + # Jobs are a member's own notepad: admins change theirs, not other members'. + cannot %i(update destroy), Job + can :update, Job, owner_id: member.id + can :destroy, Job, owner_id: member.id + can :update, Job, garden: { garden_collaborators: { member_id: member.id } } + can :destroy, Job, garden: { garden_collaborators: { member_id: member.id } } end end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 45d4b81ce..43b7d4271 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -264,6 +264,24 @@ describe Ability do ability.should_not be_able_to(:destroy, other_job) end + context 'when an admin' do + let(:role) { create(:admin) } + + before { member.roles << role } + + it "can't update another member's job" do + ability.should_not be_able_to(:update, other_job) + end + + it "can't destroy another member's job" do + ability.should_not be_able_to(:destroy, other_job) + end + + it 'can update their own job' do + ability.should be_able_to(:update, job) + end + end + context 'garden collaborator' do let(:garden) { create(:garden) } let(:job_in_garden) { create(:job, garden:) } From 0494796e86a50e2cbfd9b6bffe7dde85434a6937 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:10:59 +0000 Subject: [PATCH 24/30] Fade out flash messages 3s after user interaction Automatically fade out flash alert messages 3 seconds after the user begins interacting with content (scroll, touch, click, keydown, focusin). Following UX best practices, the auto-dismiss timer is paused while the user hovers over or focuses inside the alert notification itself. Co-authored-by: CloCkWeRX <365751+CloCkWeRX@users.noreply.github.com> --- app/helpers/application_helper.rb | 2 +- app/javascript/application.js | 2 + app/javascript/site/flash_messages.js | 115 ++++++++++++++++ app/javascript/site/flash_messages.test.js | 147 +++++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 app/javascript/site/flash_messages.js create mode 100644 app/javascript/site/flash_messages.test.js diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f41a09b69..14a909efd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -47,7 +47,7 @@ module ApplicationHelper end def build_alert_classes(alert_type = :info) - classes = 'alert alert-dismissible ' + classes = 'alert alert-dismissible fade show ' case alert_type.to_sym when :alert, :danger, :error, :validation_errors classes += 'alert-danger' diff --git a/app/javascript/application.js b/app/javascript/application.js index 60bab4522..ae1fe911d 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -21,8 +21,10 @@ import './site/navbar_search'; import './site/places'; import './site/planting_alternate_name'; import './site/tab_navigation'; +import { initFlashMessages } from './site/flash_messages'; document.addEventListener('DOMContentLoaded', function() { + initFlashMessages(); var popoverTrigger = Array.prototype.slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')); popoverTrigger.map(function(popoverTrigger2) { return new bootstrap.Popover(popoverTrigger2); diff --git a/app/javascript/site/flash_messages.js b/app/javascript/site/flash_messages.js new file mode 100644 index 000000000..9c1a32472 --- /dev/null +++ b/app/javascript/site/flash_messages.js @@ -0,0 +1,115 @@ +export function initFlashMessages(delay = 3000) { + const flashContainer = document.querySelector('.flash-messages'); + if (!flashContainer) return; + + const alerts = Array.from(flashContainer.querySelectorAll('.alert')); + if (alerts.length === 0) return; + + alerts.forEach((alertEl) => setupAutoDismiss(alertEl, delay)); +} + +function setupAutoDismiss(alertEl, delay) { + let timer = null; + let interactionStarted = false; + let isHoveredOrFocused = false; + + const startTimer = () => { + if (timer) clearTimeout(timer); + if (isHoveredOrFocused) return; + + timer = setTimeout(() => { + dismissAlert(alertEl); + }, delay); + }; + + const pauseTimer = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + }; + + const handleInteraction = (event) => { + // Ignore interaction events originating inside the alert itself + if (event.target && (event.target instanceof Node) && alertEl.contains(event.target)) return; + + if (!interactionStarted) { + interactionStarted = true; + detachInteractionListeners(); + } + + startTimer(); + }; + + const interactionEvents = ['scroll', 'touchstart', 'mousedown', 'keydown', 'focusin']; + + const attachInteractionListeners = () => { + interactionEvents.forEach((type) => { + window.addEventListener(type, handleInteraction, { capture: true, passive: true }); + }); + }; + + const detachInteractionListeners = () => { + interactionEvents.forEach((type) => { + window.removeEventListener(type, handleInteraction, { capture: true }); + }); + }; + + // Pause on hover or focus within the alert + const onMouseEnter = () => { + isHoveredOrFocused = true; + pauseTimer(); + }; + + const onMouseLeave = () => { + isHoveredOrFocused = false; + if (interactionStarted) { + startTimer(); + } + }; + + const onFocusIn = (e) => { + if (alertEl.contains(e.target)) { + isHoveredOrFocused = true; + pauseTimer(); + } + }; + + const onFocusOut = (e) => { + if (!alertEl.contains(e.relatedTarget)) { + isHoveredOrFocused = false; + if (interactionStarted) { + startTimer(); + } + } + }; + + alertEl.addEventListener('mouseenter', onMouseEnter); + alertEl.addEventListener('mouseleave', onMouseLeave); + alertEl.addEventListener('focusin', onFocusIn); + alertEl.addEventListener('focusout', onFocusOut); + + // Clean up if alert is dismissed manually + alertEl.addEventListener('closed.bs.alert', () => { + pauseTimer(); + detachInteractionListeners(); + }); + + attachInteractionListeners(); +} + +function dismissAlert(alertEl) { + if (window.bootstrap && window.bootstrap.Alert) { + const bsAlert = window.bootstrap.Alert.getOrCreateInstance(alertEl); + if (bsAlert) { + bsAlert.close(); + return; + } + } + + // Fallback if Bootstrap JS object is not loaded + alertEl.classList.remove('show'); + setTimeout(() => { + alertEl.remove(); + }, 150); +} diff --git a/app/javascript/site/flash_messages.test.js b/app/javascript/site/flash_messages.test.js new file mode 100644 index 000000000..d315c32ea --- /dev/null +++ b/app/javascript/site/flash_messages.test.js @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initFlashMessages } from './flash_messages'; + +describe('initFlashMessages', () => { + let flashContainer; + let alertEl; + + beforeEach(() => { + vi.useFakeTimers(); + + document.body.innerHTML = ` +
+ +
+
+ + +
+ `; + + flashContainer = document.querySelector('.flash-messages'); + alertEl = document.querySelector('.alert'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not dismiss flash messages automatically before user interaction', () => { + initFlashMessages(3000); + + vi.advanceTimersByTime(5000); + + expect(document.querySelector('.alert')).not.toBeNull(); + }); + + it('starts 3-second auto-dismiss on window scroll user interaction', () => { + initFlashMessages(3000); + + window.dispatchEvent(new Event('scroll')); + + vi.advanceTimersByTime(2900); + expect(document.querySelector('.alert')).not.toBeNull(); + + vi.advanceTimersByTime(300); + // After fallback animation timeout (150ms), element is removed if bootstrap JS object is absent + vi.advanceTimersByTime(200); + expect(document.querySelector('.alert')).toBeNull(); + }); + + it('starts 3-second auto-dismiss on touchstart interaction', () => { + initFlashMessages(3000); + + window.dispatchEvent(new Event('touchstart')); + + vi.advanceTimersByTime(3500); + expect(document.querySelector('.alert')).toBeNull(); + }); + + it('starts 3-second auto-dismiss on focusin outside the alert', () => { + initFlashMessages(3000); + + const outsideInput = document.getElementById('outside-input'); + outsideInput.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + vi.advanceTimersByTime(3500); + expect(document.querySelector('.alert')).toBeNull(); + }); + + it('pauses auto-dismiss timer while mouse hovers over the alert and resumes when unhovered', () => { + initFlashMessages(3000); + + // Trigger user interaction + window.dispatchEvent(new Event('scroll')); + + vi.advanceTimersByTime(2000); + + // Hover over alert + alertEl.dispatchEvent(new MouseEvent('mouseenter')); + + // Advance past original 3 second mark + vi.advanceTimersByTime(2000); + expect(document.querySelector('.alert')).not.toBeNull(); + + // Mouse leaves alert + alertEl.dispatchEvent(new MouseEvent('mouseleave')); + + // Advance 2.9 seconds: still there + vi.advanceTimersByTime(2900); + expect(document.querySelector('.alert')).not.toBeNull(); + + // Advance another 300ms + fallback + vi.advanceTimersByTime(300 + 200); + expect(document.querySelector('.alert')).toBeNull(); + }); + + it('pauses auto-dismiss timer while focus is inside the alert', () => { + initFlashMessages(3000); + + // Trigger user interaction + window.dispatchEvent(new Event('scroll')); + + vi.advanceTimersByTime(2000); + + // Focus on input inside alert + const alertInput = document.getElementById('alert-input'); + alertEl.dispatchEvent(new FocusEvent('focusin', { bubbles: true, target: alertInput })); + + vi.advanceTimersByTime(3000); + expect(document.querySelector('.alert')).not.toBeNull(); + + // Focus moves outside alert + const outsideBtn = document.getElementById('outside-btn'); + alertEl.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outsideBtn })); + + vi.advanceTimersByTime(3500); + expect(document.querySelector('.alert')).toBeNull(); + }); + + it('uses window.bootstrap.Alert when available', () => { + const closeSpy = vi.fn(() => { + alertEl.remove(); + }); + + window.bootstrap = { + Alert: { + getOrCreateInstance: vi.fn(() => ({ + close: closeSpy, + })), + }, + }; + + initFlashMessages(3000); + + window.dispatchEvent(new Event('scroll')); + + vi.advanceTimersByTime(3100); + + expect(window.bootstrap.Alert.getOrCreateInstance).toHaveBeenCalledWith(alertEl); + expect(closeSpy).toHaveBeenCalled(); + delete window.bootstrap; + }); +}); From 4271f413406147b20ed648464767852ce8c4c6b9 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Sat, 24 Oct 2026 04:08:49 +0000 Subject: [PATCH 25/30] Drop to 2 seconds --- app/javascript/site/flash_messages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/site/flash_messages.js b/app/javascript/site/flash_messages.js index 9c1a32472..05c156dc2 100644 --- a/app/javascript/site/flash_messages.js +++ b/app/javascript/site/flash_messages.js @@ -1,4 +1,4 @@ -export function initFlashMessages(delay = 3000) { +export function initFlashMessages(delay = 2000) { const flashContainer = document.querySelector('.flash-messages'); if (!flashContainer) return; From a3a2006ed9cd5e5e184c17e0f2c9a701cfbf727d Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 20:29:07 +1300 Subject: [PATCH 26/30] Wrap long job names on the notepad, clip very long ones (#5079) * Add responsive styling to notepad name for better text handling * Add notepad-pill mixin for consistent styling of job chips --- app/assets/stylesheets/_jobs.scss | 39 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/app/assets/stylesheets/_jobs.scss b/app/assets/stylesheets/_jobs.scss index 81c62af8e..09eca11d8 100644 --- a/app/assets/stylesheets/_jobs.scss +++ b/app/assets/stylesheets/_jobs.scss @@ -14,6 +14,14 @@ $notepad-trail: #a4c98c; animation: notepad-spin 0.8s linear infinite; } +// A pale green pill: chips, the link picked for a job, and the jobs an idea made. +@mixin notepad-pill { + background: $notepad-tile; + border-radius: 999px; + color: $notepad-leaf; + font-weight: 600; +} + // A member's own jobs, as a notepad: a green title bar, then rows ruled like a // list. See the garden notepad design. .notepad { @@ -337,7 +345,17 @@ $notepad-trail: #a4c98c; } } + // The name takes the room beside the tick box and wraps there; a very long + // one is clipped after three lines. .notepad-name { + flex: 1 1 8rem; + min-width: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + overflow-wrap: anywhere; color: #212121; font-weight: 500; text-decoration: none; @@ -349,11 +367,8 @@ $notepad-trail: #a4c98c; gap: 0.35rem; min-height: 28px; padding: 0 0.75rem; - border-radius: 999px; - background: $notepad-tile; - color: $notepad-leaf; + @include notepad-pill; font-size: 0.8rem; - font-weight: 600; text-decoration: none; &.garden { @@ -570,11 +585,8 @@ $notepad-trail: #a4c98c; align-items: center; min-height: 28px; padding: 0 0.75rem; - border-radius: 999px; - background: $notepad-tile; - color: $notepad-leaf; + @include notepad-pill; font-size: 0.8rem; - font-weight: 600; &[hidden] { display: none; @@ -589,11 +601,8 @@ $notepad-trail: #a4c98c; margin: 0.4rem 1.1rem 0 2.8rem; padding: 0 0.25rem 0 0.75rem; min-height: 32px; - border-radius: 999px; - background: $notepad-tile; - color: $notepad-leaf; + @include notepad-pill; font-size: 0.85rem; - font-weight: 600; &[hidden] { display: none; @@ -625,7 +634,6 @@ $notepad-trail: #a4c98c; } } - // Crossing a job off: flowers blooming out of the tick box, a row of them // growing along the bottom of the page for a streak, and a sticky note with an // Undo toast in the corner. They sit outside the notepad, on the page. @@ -1048,11 +1056,8 @@ $notepad-trail: #a4c98c; .idea-added li { animation: idea-job-in 0.4s ease both; - background: $notepad-tile; - border-radius: 999px; - color: $notepad-leaf; + @include notepad-pill; font-size: 0.85rem; - font-weight: 600; padding: 0.15rem 0.75rem; } } From 8474bdd4161a2d1cef51ce040241b66e673392b2 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Mon, 26 Oct 2026 14:18:36 +1300 Subject: [PATCH 27/30] remove newsletter (#4974) * Remove unused bluecloth gem Last released in 2011. It was only required, never called; kramdown renders the markdown. * Add specs for Tilt markdown rendering and posts markdown partial * Remove rails_12factor gem Last released in 2014. Rails 5+ does this via RAILS_SERVE_STATIC_FILES and RAILS_LOG_TO_STDOUT, which production.rb already reads. * Remove memcachier gem from Gemfile and Gemfile.lock * Remove unused webrat gem Last released in 2011. Nothing requires it; the specs use Rails' assert_select and Capybara matchers. Only generator comments mention it. * Remove crowdin-cli gem Last released in 2018. Crowdin now ships its own standalone CLI, which reads crowdin.yml and doesn't need the gem. Also drops the dependencies only it used (rest-client, gli, crowdin-api and friends). * Removes the crowdin-cli gem (last released 2018), the dependencies only it needed, and crowdin.yml. Crowdin was never set up: the config still has a placeholder project ID and nothing else in the repo mentions it. Assisted by Claude * Remove newsletter functionality and associated views and tests * Remove newsletter functionality and related code across the application * keep the column * Remove stale test step * add back newsletter * Add migration to update newsletter column comment in members table --------- Co-authored-by: Daniel O'Connor --- .rubocop_todo.yml | 1 - Gemfile | 1 - Gemfile.lock | 10 ---- app/controllers/admin_controller.rb | 6 --- app/controllers/application_controller.rb | 4 +- app/controllers/members_controller.rb | 2 +- app/controllers/registrations_controller.rb | 2 +- app/javascript/components/SignUpModal.jsx | 3 +- .../components/SignUpModal.test.jsx | 5 -- app/models/concerns/member_newsletter.rb | 48 ------------------- app/models/member.rb | 1 - app/views/admin/index.html.haml | 1 - app/views/admin/newsletter.html.haml | 7 --- .../registrations/_edit_email.html.haml | 8 ---- .../registrations/_newsletter_blurb.html.haml | 5 -- app/views/devise/registrations/new.html.haml | 2 - app/views/members/finish_signup.haml | 8 ---- config/application.rb | 6 --- config/routes.rb | 1 - ...025000000_comment_on_members_newsletter.rb | 11 +++++ db/schema.rb | 4 +- env-example | 9 ---- spec/controllers/admin_controller_spec.rb | 15 ++---- spec/factories/member.rb | 4 -- spec/features/admin/newsletter_spec.rb | 22 --------- spec/models/member_spec.rb | 30 ------------ spec/routing/admin_controller_routing_spec.rb | 1 - spec/views/admin/index_spec.rb | 4 -- spec/views/devise/registrations/edit_spec.rb | 4 -- spec/views/devise/registrations/new_spec.rb | 4 -- 30 files changed, 21 insertions(+), 208 deletions(-) delete mode 100644 app/models/concerns/member_newsletter.rb delete mode 100644 app/views/admin/newsletter.html.haml delete mode 100644 app/views/devise/registrations/_newsletter_blurb.html.haml create mode 100644 db/migrate/20261025000000_comment_on_members_newsletter.rb delete mode 100644 spec/features/admin/newsletter_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index e8b6b9e14..ab6e6b08b 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -811,7 +811,6 @@ Style/OpenStructUse: Style/OptionalBooleanParameter: Exclude: - 'app/helpers/application_helper.rb' - - 'app/models/concerns/member_newsletter.rb' # Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). diff --git a/Gemfile b/Gemfile index b20559463..d8b6db339 100644 --- a/Gemfile +++ b/Gemfile @@ -45,7 +45,6 @@ gem 'flickraw' gem 'cancancan' # for checking member privileges gem 'csv_shaper' # CSV export gem 'flipper-active_record' # feature flags admins switch on and off -gem 'gibbon', '~>1.2.0' # for Mailchimp newsletter subscriptions gem 'pg' diff --git a/Gemfile.lock b/Gemfile.lock index e91656445..8e5bdc27e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -224,9 +224,6 @@ GEM geocoder (1.8.6) base64 (>= 0.1.0) csv (>= 3.0.0) - gibbon (1.2.1) - httparty - multi_json (>= 1.9.0) globalid (1.4.0) activesupport (>= 6.1) gravatar-ultimate (2.0.0) @@ -252,10 +249,6 @@ GEM logger highline (3.1.2) reline - httparty (0.24.0) - csv - mini_mime (>= 1.0.0) - multi_xml (>= 0.5.2) i18n (1.15.2) concurrent-ruby (~> 1.0) i18n-tasks (1.1.2) @@ -337,8 +330,6 @@ GEM prism (~> 1.5) msgpack (1.8.5) multi_json (1.19.1) - multi_xml (0.8.1) - bigdecimal (>= 3.1, < 5) net-http (0.9.1) uri (>= 0.11.1) net-imap (0.6.8) @@ -673,7 +664,6 @@ DEPENDENCIES friendly_id gbifrb geocoder - gibbon (~> 1.2.0) gravatar-ultimate haml haml-rails diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 166c06c0f..718095ca9 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -5,10 +5,4 @@ class AdminController < ApplicationController def index authorize! :manage, :all end - - def newsletter - authorize! :manage, :all - @members = Member.confirmed.wants_newsletter.all.paginate(page: params[:page], per_page: 100) - respond_with @members - end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 989b9ee69..a240925ed 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -79,7 +79,7 @@ class ApplicationController < ActionController::Base # profile stuff :bio, :location, :latitude, :longitude, # email settings - :show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder) + :show_email, :send_notification_email, :send_planting_reminder, :send_harvest_reminder) end devise_parameter_sanitizer.permit(:account_update) do |member| @@ -91,7 +91,7 @@ class ApplicationController < ActionController::Base :bio, :location, :latitude, :longitude, :website_url, :instagram_handle, :facebook_handle, :bluesky_handle, :other_url, # email settings - :show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder, + :show_email, :send_notification_email, :send_planting_reminder, :send_harvest_reminder, # update password :current_password) end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index a2d89f21b..ab967aadf 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -92,7 +92,7 @@ class MembersController < ApplicationController }.freeze def member_params - params.require(:member).permit(:login_name, :tos_agreement, :email, :newsletter, :send_harvest_reminder) + params.require(:member).permit(:login_name, :tos_agreement, :email, :send_harvest_reminder) end def member_json_fields diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index c7252a151..7d8b86a4a 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -72,7 +72,7 @@ class RegistrationsController < Devise::RegistrationsController end def sign_up_params - params.require(:member).permit(:login_name, :email, :tos_agreement, :newsletter, :password, :password_confirmation) + params.require(:member).permit(:login_name, :email, :tos_agreement, :password, :password_confirmation) end def check_captcha diff --git a/app/javascript/components/SignUpModal.jsx b/app/javascript/components/SignUpModal.jsx index 57aa74771..03f2e17ee 100644 --- a/app/javascript/components/SignUpModal.jsx +++ b/app/javascript/components/SignUpModal.jsx @@ -13,7 +13,7 @@ const FAILED = {byField: {}, other: ['Something went wrong joining. Please try a // JSON, shows the server's errors against their fields, and on success goes to // wherever the server would have sent the new member. export default function SignUpModal({urls, recaptchaSiteKey, siteName, onClose, onSwitch}) { - const [values, setValues] = useState({login_name: '', email: '', password: '', password_confirmation: '', tos_agreement: false, newsletter: true}); + const [values, setValues] = useState({login_name: '', email: '', password: '', password_confirmation: '', tos_agreement: false}); const [saving, setSaving] = useState(false); const [errors, setErrors] = useState({byField: {}, other: []}); const captcha = useRecaptcha(recaptchaSiteKey); @@ -64,7 +64,6 @@ export default function SignUpModal({urls, recaptchaSiteKey, siteName, onClose,
I agree to the Terms of Service} checked={values.tos_agreement} onChange={set('tos_agreement')} errors={on.tos_agreement} /> -
{recaptchaSiteKey ?
: null}
diff --git a/app/javascript/components/SignUpModal.test.jsx b/app/javascript/components/SignUpModal.test.jsx index be0154562..d33559b0d 100644 --- a/app/javascript/components/SignUpModal.test.jsx +++ b/app/javascript/components/SignUpModal.test.jsx @@ -30,11 +30,6 @@ describe('SignUpModal', () => { expect(screen.getByRole('dialog', {name: 'Join Growstuff'})).toBeInTheDocument(); }); - it('offers the newsletter, ticked', () => { - setup(); - expect(screen.getByLabelText('Subscribe to the Growstuff newsletter')).toBeChecked(); - }); - it('does not tick the terms for me', () => { setup(); expect(screen.getByLabelText(/Terms of Service/)).not.toBeChecked(); diff --git a/app/models/concerns/member_newsletter.rb b/app/models/concerns/member_newsletter.rb deleted file mode 100644 index c491914fb..000000000 --- a/app/models/concerns/member_newsletter.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -module MemberNewsletter - extend ActiveSupport::Concern - - included do - after_save :update_newsletter_subscription - before_destroy :newsletter_unsubscribe - - scope :wants_newsletter, -> { where(newsletter: true) } - - def update_newsletter_subscription - return unless will_save_change_to_attribute?(:confirmed) || will_save_change_to_attribute?(:newsletter) - - if newsletter - newsletter_subscribe if confirmed_just_now? || requested_newsletter_just_now? - elsif confirmed_at - newsletter_unsubscribe - end - end - - def confirmed_just_now? - will_save_change_to_attribute?(:confirmed_at) - end - - def requested_newsletter_just_now? - confirmed_at && will_save_change_to_attribute?(:newsletter) - end - - def newsletter_subscribe(gibbon = Gibbon::API.new, testing = false) - return true if Rails.env.test? && !testing - - gibbon.lists.subscribe( - id: Rails.application.config.newsletter_list_id, - email: { email: }, - merge_vars: { login_name: }, - double_optin: false # they already confirmed their email with us - ) - end - - def newsletter_unsubscribe(gibbon = Gibbon::API.new, testing = false) - return true if Rails.env.test? && !testing - - gibbon.lists.unsubscribe(id: Rails.application.config.newsletter_list_id, - email: { email: }) - end - end -end diff --git a/app/models/member.rb b/app/models/member.rb index 7e98211f7..24e8da4f8 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -6,7 +6,6 @@ class Member < ApplicationRecord acts_as_messageable # messages can be sent to this model include Geocodable include MemberFlickr - include MemberNewsletter extend FriendlyId diff --git a/app/views/admin/index.html.haml b/app/views/admin/index.html.haml index a6e37c227..44dedfe7c 100644 --- a/app/views/admin/index.html.haml +++ b/app/views/admin/index.html.haml @@ -41,5 +41,4 @@ Member admin .card-body %nav.nav#member_admin - %li= link_to "Newsletter subscribers", admin_newsletter_path, class: 'nav-link' %li= link_to "Members", admin_members_path, class: 'nav-link' diff --git a/app/views/admin/newsletter.html.haml b/app/views/admin/newsletter.html.haml deleted file mode 100644 index f6ad57c1f..000000000 --- a/app/views/admin/newsletter.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -- content_for :title, 'Newsletter subscribers' - -%p -- @members.each do |m| - = m.email - %br/ -= will_paginate @members diff --git a/app/views/devise/registrations/_edit_email.html.haml b/app/views/devise/registrations/_edit_email.html.haml index 88ab6e4a5..c3665270c 100644 --- a/app/views/devise/registrations/_edit_email.html.haml +++ b/app/views/devise/registrations/_edit_email.html.haml @@ -34,14 +34,6 @@ = f.check_box :send_harvest_reminder Receive regular reminders of upcoming harvests. - .row.mb-3 - .offset-md-2.col-md-8.checkbox - %label - = f.check_box :newsletter - Subscribe to the #{ENV['GROWSTUFF_SITE_NAME']} newsletter - .form-text - = render partial: 'newsletter_blurb' - .row.mb-3 .offset-md-2.col-md-8 = f.submit "Save", class: 'btn btn-primary' diff --git a/app/views/devise/registrations/_newsletter_blurb.html.haml b/app/views/devise/registrations/_newsletter_blurb.html.haml deleted file mode 100644 index 3ba06ba71..000000000 --- a/app/views/devise/registrations/_newsletter_blurb.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -The #{ENV['GROWSTUFF_SITE_NAME']} newsletter is sent out -every few weeks. Check out our -= link_to 'past newsletters', 'http://blog.growstuff.org/category/newsletter/' -if you want to see the sort of thing we'll send you. Of course, we'll never spam you -or sell your email address, and you can unsubscribe any time. diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 0dc183a36..4165f539e 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -13,8 +13,6 @@ = f.password_field :password_confirmation = f.check_box :tos_agreement, label: safe_join(['I agree to the ', link_to('Terms of Service', '/policy/tos')]) - = f.check_box :newsletter, checked: true, label: "Subscribe to the #{site_name} newsletter" - %p.form-text= render 'newsletter_blurb' = f.submit 'Sign up', class: 'btn w-100 btn-success' diff --git a/app/views/members/finish_signup.haml b/app/views/members/finish_signup.haml index 1ab8de4d2..7e2403743 100644 --- a/app/views/members/finish_signup.haml +++ b/app/views/members/finish_signup.haml @@ -28,13 +28,5 @@ I agree to the = succeed "." do = link_to 'Terms of Service', "#{root_url}/policy/tos" - .mb-3 - .offset-md-2.col-md-8.checkbox - %label - = f.check_box :newsletter, checked: true - Subscribe to the #{ENV['GROWSTUFF_SITE_NAME']} newsletter - .help-inline - = render partial: 'devise/registrations/newsletter_blurb' - .actions = f.submit 'Continue', class: 'btn btn-primary' diff --git a/config/application.rb b/config/application.rb index 75a7af267..88a3405e1 100644 --- a/config/application.rb +++ b/config/application.rb @@ -72,12 +72,6 @@ module Growstuff config.user_agent = 'Growstuff' config.user_agent_email = "info@growstuff.org" - Gibbon::API.api_key = ENV['GROWSTUFF_MAILCHIMP_APIKEY'] || 'notarealkey' - # API key can't be blank or tests fail - Gibbon::API.timeout = 10 - Gibbon::API.throws_exceptions = false - config.newsletter_list_id = ENV.fetch('GROWSTUFF_MAILCHIMP_NEWSLETTER_ID', nil) - # config.active_record.raise_in_transactional_callbacks = true config.middleware.insert_before 0, Rack::Attack diff --git a/config/routes.rb b/config/routes.rb index 8a693671b..cdd07d295 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -181,7 +181,6 @@ Rails.application.routes.draw do scope :admin do get '/' => 'admin#index', as: 'admin' - get '/newsletter' => 'admin#newsletter', as: 'admin_newsletter' end namespace :admin do diff --git a/db/migrate/20261025000000_comment_on_members_newsletter.rb b/db/migrate/20261025000000_comment_on_members_newsletter.rb new file mode 100644 index 000000000..01525acbe --- /dev/null +++ b/db/migrate/20261025000000_comment_on_members_newsletter.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# The newsletter feature has been removed, but the column is kept in case we +# bring it back. +class CommentOnMembersNewsletter < ActiveRecord::Migration[8.1] + COMMENT = 'From when we had a newsletter. Unused now, kept in case we bring it back.' + + def change + change_column_comment :members, :newsletter, from: nil, to: COMMENT + end +end diff --git a/db/schema.rb b/db/schema.rb index 521a68788..d65de7627 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_24_100000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_25_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -859,7 +859,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_100000) do t.datetime "locked_at", precision: nil t.string "login_name" t.float "longitude" - t.boolean "newsletter" + t.boolean "newsletter", comment: "From when we had a newsletter. Unused now, kept in case we bring it back." t.string "other_url" t.integer "photos_count" t.integer "plantings_count" diff --git a/env-example b/env-example index 63b2bd348..92a127fdf 100644 --- a/env-example +++ b/env-example @@ -31,15 +31,6 @@ RAILS_SECRET_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # name that appears on the site, eg. in page titles GROWSTUFF_SITE_NAME="Growstuff (dev)" -# Mailchimp is used for subscribing/unsubscribing people from the newsletter -# To fetch list IDs using Gibbon (and thus find the ID of your newsletter): -# $ rails c -# Gibbon::API.api_key = '...' -# gb = Gibbon::API.new -# puts gb.lists.list.to_yaml -GROWSTUFF_MAILCHIMP_APIKEY="" -GROWSTUFF_MAILCHIMP_NEWSLETTER_ID="" - # Used for connecting member accounts to Flickr # Get Flickr key from http://www.flickr.com/services/apps/create/apply/ GROWSTUFF_FLICKR_KEY="" diff --git a/spec/controllers/admin_controller_spec.rb b/spec/controllers/admin_controller_spec.rb index 21999e427..f9de3d1a2 100644 --- a/spec/controllers/admin_controller_spec.rb +++ b/spec/controllers/admin_controller_spec.rb @@ -5,18 +5,9 @@ require 'rails_helper' describe AdminController do login_member(:admin_member) - describe "GET admin/newsletter" do - before { get :newsletter } + describe "GET admin" do + before { get :index } - describe 'fetches the admin newsletter page' do - it { expect(response).to be_successful } - it { expect(response).to render_template("admin/newsletter") } - end - - describe 'assigns @members' do - let!(:m) { create(:newsletter_recipient_member) } - - it { expect(assigns(:members)).to eq [m] } - end + it { expect(response).to be_successful } end end diff --git a/spec/factories/member.rb b/spec/factories/member.rb index 3a02e2c3c..6728e9d2c 100644 --- a/spec/factories/member.rb +++ b/spec/factories/member.rb @@ -20,10 +20,6 @@ FactoryBot.define do tos_agreement { false } end - factory :newsletter_recipient_member do - newsletter { true } - end - factory :no_bio_member do bio { nil } end diff --git a/spec/features/admin/newsletter_spec.rb b/spec/features/admin/newsletter_spec.rb deleted file mode 100644 index 9111f95eb..000000000 --- a/spec/features/admin/newsletter_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe "newsletter subscribers", :js do - include_context 'signed in admin' - - let(:subscriber) { create(:newsletter_recipient_member) } - - describe "navigating to newsletter subscribers admin with js" do - before do - @subscriber = subscriber - visit admin_path - within 'nav#member_admin' do - click_link "Newsletter subscribers" - end - end - - it { expect(page).to have_current_path admin_newsletter_path, ignore_query: true } - it { expect(page).to have_content @subscriber.email } - end -end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index b1cd7d5cc..4cafee56b 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -100,15 +100,6 @@ describe Member do end end - context 'newsletter scope' do - it 'finds newsletter recipients' do - regular_member = create(:member) - newsletter_member = create(:newsletter_recipient_member) - Member.wants_newsletter.should include newsletter_member - Member.wants_newsletter.should_not include regular_member - end - end - context 'same :login_name' do it "does not allow two members with the same login_name" do create(:member, login_name: "bob") @@ -339,21 +330,6 @@ describe Member do end end - context 'subscriptions' do - let(:member) { create(:member) } - let(:gb) { instance_double("Gibbon::API.new") } - - it 'subscribes to the newsletter' do - expect(gb).to receive_message_chain('lists.subscribe') - member.newsletter_subscribe(gb, true) - end - - it 'unsubscribes from the newsletter' do - expect(gb).to receive_message_chain('lists.unsubscribe') - member.newsletter_unsubscribe(gb, true) - end - end - context 'member deleted' do let(:member) { create(:member) } @@ -365,16 +341,10 @@ describe Member do it { expect(Member.located).not_to include(member) } it { expect(Member.recently_signed_in).not_to include(member) } it { expect(Member.recently_joined).not_to include(member) } - it { expect(Member.wants_newsletter).not_to include(member) } it { expect(Member.interesting).not_to include(member) } it { expect(Member.has_plantings).not_to include(member) } end - it "unsubscribes from mailing list" do - expect(member).to receive(:newsletter_unsubscribe).and_return(true) - member.destroy - end - context "deleted admin member" do let(:member) { create(:admin_member) } diff --git a/spec/routing/admin_controller_routing_spec.rb b/spec/routing/admin_controller_routing_spec.rb index 287bb2976..ce2c9d3ef 100644 --- a/spec/routing/admin_controller_routing_spec.rb +++ b/spec/routing/admin_controller_routing_spec.rb @@ -6,6 +6,5 @@ describe AdminController do describe "routing" do it { expect(get("/admin/")).to route_to("admin#index") } it { expect(get("/admin/members")).to route_to("admin/members#index") } - it { expect(get("/admin/newsletter")).to route_to("admin#newsletter") } end end diff --git a/spec/views/admin/index_spec.rb b/spec/views/admin/index_spec.rb index 066d43b16..ef6270f98 100644 --- a/spec/views/admin/index_spec.rb +++ b/spec/views/admin/index_spec.rb @@ -14,8 +14,4 @@ describe 'admin/index.html.haml', type: "view" do assert_select "a", href: admin_roles_path assert_select "a", href: forums_path end - - it "has a link to newsletter subscribers" do - expect(rendered).to have_content "Newsletter subscribers" - end end diff --git a/spec/views/devise/registrations/edit_spec.rb b/spec/views/devise/registrations/edit_spec.rb index 546da02f4..9805a7326 100644 --- a/spec/views/devise/registrations/edit_spec.rb +++ b/spec/views/devise/registrations/edit_spec.rb @@ -27,10 +27,6 @@ describe 'devise/registrations/edit.html.haml', type: "view" do it 'has a checkbox for email notifications' do assert_select "input[id=member_send_notification_email][type=checkbox]" end - - it 'has a checkbox for newsletter subscription' do - assert_select "input[id=member_newsletter][type=checkbox]" - end end context 'profile section' do diff --git a/spec/views/devise/registrations/new_spec.rb b/spec/views/devise/registrations/new_spec.rb index e4e7ef3d2..49c37c907 100644 --- a/spec/views/devise/registrations/new_spec.rb +++ b/spec/views/devise/registrations/new_spec.rb @@ -15,9 +15,5 @@ describe 'devise/registrations/new.html.haml', type: "view" do it 'has some fields' do expect(rendered).to have_content 'Email' end - - it 'has a checkbox for newsletter subscription' do - assert_select "input[id=member_newsletter][type=checkbox]" - end end end From 17f004f89371ec0dddf093b0d5446a312b9fadf1 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 19:03:33 +1300 Subject: [PATCH 28/30] Add npm to dependabot --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9b538e89c..6e0434524 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,14 @@ updates: time: "07:00" open-pull-requests-limit: 10 +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + time: "07:00" + open-pull-requests-limit: 10 + groups: + minor-and-patch: + update-types: + - minor + - patch From e53409b9580d58d57cb90e46150809073dfd3c56 Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sun, 25 Oct 2026 19:04:29 +1300 Subject: [PATCH 29/30] Adding docker to dependabot --- .github/dependabot.yml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e0434524..3ff34efb7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,13 +7,17 @@ updates: time: "07:00" open-pull-requests-limit: 20 versioning-strategy: lockfile-only + groups: + minor-and-patch: + update-types: + - minor + - patch - package-ecosystem: github-actions directory: "/" schedule: interval: daily time: "07:00" open-pull-requests-limit: 10 - - package-ecosystem: npm directory: "/" schedule: @@ -25,3 +29,30 @@ updates: update-types: - minor - patch +- package-ecosystem: docker + directories: + - "/" + - "/.devcontainer" + schedule: + interval: daily + time: "07:00" + ignore: + - dependency-name: ruby + update-types: + - version-update:semver-major + - version-update:semver-minor + - dependency-name: mcr.microsoft.com/devcontainers/ruby + update-types: + - version-update:semver-major + - version-update:semver-minor +- package-ecosystem: docker-compose + directories: + - "/" + - "/.devcontainer" + schedule: + interval: daily + time: "07:00" + ignore: + - dependency-name: postgres + update-types: + - version-update:semver-major From a24fe9aeccab5783d5156be05ad768b2b8fec5b4 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 26 Oct 2026 12:36:32 +1030 Subject: [PATCH 30/30] Drop docker, docker compose for now --- .github/dependabot.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3ff34efb7..f55e262f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -29,30 +29,3 @@ updates: update-types: - minor - patch -- package-ecosystem: docker - directories: - - "/" - - "/.devcontainer" - schedule: - interval: daily - time: "07:00" - ignore: - - dependency-name: ruby - update-types: - - version-update:semver-major - - version-update:semver-minor - - dependency-name: mcr.microsoft.com/devcontainers/ruby - update-types: - - version-update:semver-major - - version-update:semver-minor -- package-ecosystem: docker-compose - directories: - - "/" - - "/.devcontainer" - schedule: - interval: daily - time: "07:00" - ignore: - - dependency-name: postgres - update-types: - - version-update:semver-major