+
+
+
+
+ `;
+
+ 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;
+ });
+});
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 a03e7d2c5..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(); };
@@ -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,14 +54,16 @@ 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',
- 'X-CSRF-Token': token ? token.content : '',
+ 'X-CSRF-Token': csrfToken(),
},
credentials: 'same-origin',
})
@@ -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;
@@ -239,6 +253,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/models/ability.rb b/app/models/ability.rb
index ea2085278..83c0c2605 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -192,5 +192,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/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/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/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/models/member.rb b/app/models/member.rb
index e3e37dd98..e945bbc0c 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
@@ -82,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/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/services/job_list.rb b/app/services/job_list.rb
index d302d052c..8c601ca37 100644
--- a/app/services/job_list.rb
+++ b/app/services/job_list.rb
@@ -1,16 +1,28 @@
# 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
-# 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.
+# 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 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
+
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 +30,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
@@ -25,19 +47,26 @@ 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, 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 +88,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/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/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/admin/crop_job_templates/index.html.haml b/app/views/admin/crop_job_templates/index.html.haml
index b18536ab8..1b7597537 100644
--- a/app/views/admin/crop_job_templates/index.html.haml
+++ b/app/views/admin/crop_job_templates/index.html.haml
@@ -16,12 +16,13 @@
- if @templates.empty?
%p= "No job templates for #{@crop.name} yet."
- else
- %table.table
+ %table.table.job-templates-table
%thead
%tr
%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/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 18e76bd53..ce81246d6 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'
@@ -30,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
@@ -39,6 +41,5 @@
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'
%li= link_to "Photo library access", admin_photo_library_accesses_path, class: 'nav-link'
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..052c685c1
--- /dev/null
+++ b/app/views/admin/job_templates/index.html.haml
@@ -0,0 +1,44 @@
+- 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.job-templates-table
+ %thead
+ %tr
+ %th Crop
+ %th Name
+ %th Season
+ %th Written by
+ %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.creator ? link_to(template.creator, template.creator) : 'Seed data'
+ %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/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/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/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/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/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/_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/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/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/app/views/jobs/_notepad_groups.html.haml b/app/views/jobs/_notepad_groups.html.haml
index 667c129c5..8794c6167 100644
--- a/app/views/jobs/_notepad_groups.html.haml
+++ b/app/views/jobs/_notepad_groups.html.haml
@@ -5,11 +5,21 @@
%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.
+ - 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)
+ = safe_join(group.shown.map(&row_for))
+ - else
+ = safe_join(group.shown.map(&row_for))
+ - if group.older.any?
+ %details.notepad-older
+ %summary= t('jobs.pad.show_older', count: group.older.size)
+ = safe_join(group.older.map(&row_for))
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/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/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
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/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)
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/locales/en.yml b/config/locales/en.yml
index 526e6b13e..35b4d1698 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -125,6 +125,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:
@@ -705,7 +723,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
@@ -716,7 +735,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}
@@ -769,6 +790,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:
@@ -808,6 +834,13 @@ 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
+ 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
@@ -822,12 +855,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/config/routes.rb b/config/routes.rb
index f77dc02f7..f5b5e9d20 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -184,11 +184,13 @@ Rails.application.routes.draw do
scope :admin do
get '/' => 'admin#index', as: 'admin'
- get '/newsletter' => 'admin#newsletter', as: 'admin_newsletter'
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 :photo_library_accesses, only: :index do
member do
@@ -197,6 +199,8 @@ Rails.application.routes.draw do
post :revoke
end
end
+ 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/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/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/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 0bf39ae95..c635e4a72 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_000000) 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"
@@ -545,11 +545,13 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) 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.index ["creator_id"], name: "index_crop_job_templates_on_creator_id"
t.index ["crop_id"], name: "index_crop_job_templates_on_crop_id"
end
@@ -602,6 +604,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) 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"
@@ -841,7 +859,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) 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"
@@ -1107,6 +1125,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_24_000000) 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
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/env-example b/env-example
index 1159f83e5..92d1c7c1a 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/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"
diff --git a/package.json b/package.json
index c69ada4d7..3405d0f8d 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,7 @@
"sass": "^1.105.2"
},
"engines": {
- "node": ">=6.0.0",
+ "node": "24.x",
"yarn": ">=0.25.2"
},
"devDependencies": {
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/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
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/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/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:) }
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/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/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/admin/job_templates_controller_spec.rb b/spec/requests/admin/job_templates_controller_spec.rb
new file mode 100644
index 000000000..a87906f0c
--- /dev/null
+++ b/spec/requests/admin/job_templates_controller_spec.rb
@@ -0,0 +1,80 @@
+# 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) }
+ let(:author) { create(:member, login_name: 'template_author') }
+
+ before do
+ 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
+
+ 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 '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
+ 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
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
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/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/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
diff --git a/spec/services/job_list_spec.rb b/spec/services/job_list_spec.rb
index 97e5c22eb..3c39ece31 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
@@ -116,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
@@ -125,7 +149,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 +158,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 +183,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
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
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
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
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
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
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)