Merge branch 'main' into feat/photo-library-uploads

This commit is contained in:
Daniel O'Connor authored and GitHub committed 2026-10-26 12:46:39 +10:30
commit 7b5ea4b782
104 files changed
+1737 -372

No files matched your search

+16 -1
View File
@@ -7,10 +7,25 @@ updates:
time: "07:00"
open-pull-requests-limit: 20
versioning-strategy: lockfile-only
groups:
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
time: "07:00"
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
time: "07:00"
open-pull-requests-limit: 10
groups:
minor-and-patch:
update-types:
- minor
- patch
-1
View File
@@ -811,7 +811,6 @@ Style/OpenStructUse:
Style/OptionalBooleanParameter:
Exclude:
- 'app/helpers/application_helper.rb'
- 'app/models/concerns/member_newsletter.rb'
# Offense count: 1
# This cop supports unsafe autocorrection (--autocorrect-all).
+1 -1
View File
@@ -1 +1 @@
4.0.7
4.0.9
+2 -1
View File
@@ -98,8 +98,9 @@ submit the change with your pull request.
## Bots
### Security and Dependency Updates
### Agents, Security and Dependency Updates
- `codefactor-io[bot]`
- DeppBot / [deppbot](https://github.com/deppbot)
- `dependabot[bot]` [dependabot](https://github.com/dependabot-bot) / [dependabot-preview](https://github.com/apps/dependabot-preview)
- [google-labs-jules[bot]](https://github.com/apps/google-labs-jules)
- Copilot
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ruby:4.0.7-trixie
FROM ruby:4.0.9-trixie
# Install system dependencies
RUN apt-get update -qq && \
@@ -10,7 +10,7 @@ RUN apt-get update -qq && \
gnupg2 \
shared-mime-info \
libvips \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g yarn \
&& apt-get clean \
+2 -2
View File
@@ -44,7 +44,7 @@ gem 'flickraw'
gem 'cancancan' # for checking member privileges
gem 'csv_shaper' # CSV export
gem 'gibbon', '~>1.2.0' # for Mailchimp newsletter subscriptions
gem 'flipper-active_record' # feature flags admins switch on and off
gem 'pg'
@@ -140,11 +140,11 @@ end
group :development, :test do
gem 'bullet' # performance tuning by finding unnecesary queries
gem 'byebug' # debugging
gem 'capybara' # integration tests
gem 'capybara-email' # integration tests for email
gem 'capybara-screenshot' # for test debugging
gem 'database_cleaner'
gem 'debug', platforms: %i(mri windows), require: 'debug/prelude' # debugging
gem 'factory_bot_rails' # for creating test data
gem 'faker'
gem 'haml-rails' # HTML templating language
+11 -14
View File
@@ -129,8 +129,6 @@ GEM
bullet (8.3.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
byebug (13.0.0)
reline (>= 0.6.0)
cancancan (3.7.0)
capybara (3.40.0)
addressable
@@ -177,6 +175,9 @@ GEM
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.6.0)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
@@ -210,6 +211,11 @@ GEM
ffi (1.17.4)
ffi (1.17.4-x86_64-linux-gnu)
flickraw (0.9.10)
flipper (1.4.2)
concurrent-ruby (< 2)
flipper-active_record (1.4.2)
activerecord (>= 4.2, < 9)
flipper (~> 1.4.2)
foreman (0.90.0)
thor (~> 1.4)
friendly_id (5.7.0)
@@ -218,9 +224,6 @@ GEM
geocoder (1.8.6)
base64 (>= 0.1.0)
csv (>= 3.0.0)
gibbon (1.2.1)
httparty
multi_json (>= 1.9.0)
globalid (1.4.0)
activesupport (>= 6.1)
gravatar-ultimate (2.0.0)
@@ -246,10 +249,6 @@ GEM
logger
highline (3.1.2)
reline
httparty (0.24.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
i18n-tasks (1.1.2)
@@ -331,8 +330,6 @@ GEM
prism (~> 1.5)
msgpack (1.8.5)
multi_json (1.19.1)
multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.8)
@@ -643,7 +640,6 @@ DEPENDENCIES
bootstrap_form (>= 4.5.0)
bullet
bundler (>= 2.4.22)
byebug
cancancan
capybara
capybara-email
@@ -655,6 +651,7 @@ DEPENDENCIES
csv_shaper
dalli
database_cleaner
debug
devise
discard (>= 1.2)
dotenv-rails
@@ -662,11 +659,11 @@ DEPENDENCIES
faker
faraday
flickraw
flipper-active_record
foreman
friendly_id
gbifrb
geocoder
gibbon (~> 1.2.0)
gravatar-ultimate
haml
haml-rails
@@ -732,7 +729,7 @@ DEPENDENCIES
xmlrpc
RUBY VERSION
ruby 4.0.7
ruby 4.0.9
BUNDLED WITH
4.0.7
+4 -2
View File
@@ -206,7 +206,9 @@
font-size: 0.85rem;
}
// The crop wrangling table, on white so it reads against the page.
.wrangle-table {
// The crop wrangling and job template tables, on white so they read against the
// page.
.wrangle-table,
.job-templates-table {
--bs-table-bg: #{$white};
}
+88 -18
View File
@@ -14,6 +14,14 @@ $notepad-trail: #a4c98c;
animation: notepad-spin 0.8s linear infinite;
}
// A pale green pill: chips, the link picked for a job, and the jobs an idea made.
@mixin notepad-pill {
background: $notepad-tile;
border-radius: 999px;
color: $notepad-leaf;
font-weight: 600;
}
// A member's own jobs, as a notepad: a green title bar, then rows ruled like a
// list. See the garden notepad design.
.notepad {
@@ -119,12 +127,23 @@ $notepad-trail: #a4c98c;
min-height: 44px;
}
.notepad-hide-done {
margin: 0 1rem 0.5rem;
}
.notepad-note {
color: $ink-muted;
font-size: 0.9rem;
margin: 0.75rem 1.1rem 0;
}
.notepad-older summary {
color: $ink-muted;
cursor: pointer;
font-size: 0.9rem;
padding: 0.4rem 1.1rem;
}
.notepad-heading {
display: flex;
align-items: baseline;
@@ -326,7 +345,17 @@ $notepad-trail: #a4c98c;
}
}
// The name takes the room beside the tick box and wraps there; a very long
// one is clipped after three lines.
.notepad-name {
flex: 1 1 8rem;
min-width: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
overflow-wrap: anywhere;
color: #212121;
font-weight: 500;
text-decoration: none;
@@ -338,11 +367,8 @@ $notepad-trail: #a4c98c;
gap: 0.35rem;
min-height: 28px;
padding: 0 0.75rem;
border-radius: 999px;
background: $notepad-tile;
color: $notepad-leaf;
@include notepad-pill;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
&.garden {
@@ -377,18 +403,72 @@ $notepad-trail: #a4c98c;
// The menu sits at the right edge. After a due label that label has already
// pushed it there; without one the menu pushes itself.
.notepad-menu {
display: flex;
align-items: center;
margin-left: auto;
.record-actions-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
}
}
.notepad-due + .notepad-menu {
.notepad-due + .notepad-menu,
.notepad-day + .notepad-menu {
margin-left: 0;
}
.notepad-day {
position: relative;
display: flex;
align-items: center;
margin-left: auto;
// A day you can change looks like a button: outlined, with a small arrow.
.notepad-due {
margin-left: 0;
padding: 0.2rem 0.6rem;
line-height: 1.2;
border: 1px solid currentcolor;
border-radius: 1rem;
text-decoration: none;
&::after {
content: '';
display: inline-block;
margin-left: 0.4em;
vertical-align: 0.15em;
border-top: 0.3em solid;
border-right: 0.3em solid transparent;
border-left: 0.3em solid transparent;
}
&:hover,
&:focus-visible {
background: rgba(0, 0, 0, 0.06);
}
&.unset {
color: $ink-muted;
font-weight: 400;
}
}
}
// The browser's calendar opens from this, beside the day; it is never seen itself.
.notepad-date-input {
position: absolute;
right: 0;
bottom: 0;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
// A snail crawling along a trail at the foot of the page, and how many jobs
// are done this week.
.notepad-snail {
@@ -505,11 +585,8 @@ $notepad-trail: #a4c98c;
align-items: center;
min-height: 28px;
padding: 0 0.75rem;
border-radius: 999px;
background: $notepad-tile;
color: $notepad-leaf;
@include notepad-pill;
font-size: 0.8rem;
font-weight: 600;
&[hidden] {
display: none;
@@ -524,11 +601,8 @@ $notepad-trail: #a4c98c;
margin: 0.4rem 1.1rem 0 2.8rem;
padding: 0 0.25rem 0 0.75rem;
min-height: 32px;
border-radius: 999px;
background: $notepad-tile;
color: $notepad-leaf;
@include notepad-pill;
font-size: 0.85rem;
font-weight: 600;
&[hidden] {
display: none;
@@ -560,7 +634,6 @@ $notepad-trail: #a4c98c;
}
}
// Crossing a job off: flowers blooming out of the tick box, a row of them
// growing along the bottom of the page for a streak, and a sticky note with an
// Undo toast in the corner. They sit outside the notepad, on the page.
@@ -983,11 +1056,8 @@ $notepad-trail: #a4c98c;
.idea-added li {
animation: idea-job-in 0.4s ease both;
background: $notepad-tile;
border-radius: 999px;
color: $notepad-leaf;
@include notepad-pill;
font-size: 0.85rem;
font-weight: 600;
padding: 0.15rem 0.75rem;
}
}
+11
View File
@@ -359,6 +359,17 @@ footer {
z-index: $highest-level;
}
.flash-messages {
position: sticky;
top: 4rem;
z-index: $zindex-sticky;
margin-bottom: 1rem;
.alert {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
}
.alert {
a {
font-weight: 800;
@@ -10,7 +10,7 @@ module Admin
before_action :set_template, only: %i(edit update destroy)
def index
@templates = CropJobTemplate.where(crop_id: crop_ids).includes(:crop, :steps).order(:name)
@templates = CropJobTemplate.where(crop_id: crop_ids).includes(:crop, :creator, :steps).order(:name)
end
def new
@@ -24,7 +24,7 @@ module Admin
end
def create
@template = CropJobTemplate.new(template_params)
@template = CropJobTemplate.new(template_params.merge(creator: current_member))
return render_with_another_step(:new) if add_step?
if @template.save
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Admin
# The features admins can switch on and off for everyone.
class FeaturesController < ApplicationController
before_action :authenticate_member!
before_action { authorize! :manage, :all }
def index
@features = Feature.all
end
def update
feature = Feature.find(params[:id])
feature.enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
redirect_to admin_features_path, notice: t(".#{feature.enabled? ? 'on' : 'off'}", feature: t("admin.features.names.#{feature.name}"))
end
end
end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module Admin
# Every crop's job templates in one list, for crop wranglers to find the
# drafts waiting to be checked and published.
class JobTemplatesController < ApplicationController
before_action :authenticate_member!
before_action { authorize! :wrangle, Crop }
def index
@status = params[:status] if CropJobTemplate.statuses.key?(params[:status])
@templates = CropJobTemplate.joins(:crop).includes(:creator, :steps, crop: :parent).order('crops.name', :name)
@templates = @templates.where(status: @status) if @status
@templates = @templates.paginate(page: params[:page])
end
end
end
+24 -2
View File
@@ -8,8 +8,10 @@ module Admin
responders :flash
def index
@members = Member.order(:login_name)
@members = @members.where("login_name ILIKE ?", "%#{search_term}%") if search_term.present?
@status = params[:status] == 'deactivated' ? 'deactivated' : 'active'
@members = @status == 'deactivated' ? Member.discarded : Member.kept
@members = @members.includes(:roles).order(created_at: :desc)
@members = @members.where("login_name ILIKE ? OR email ILIKE ?", "%#{search_term}%", "%#{search_term}%") if search_term.present?
@members = @members.paginate(page: params[:page])
end
@@ -27,6 +29,26 @@ module Admin
def destroy
@member = Member.find_by!(slug: params[:slug])
@member.discard
redirect_to admin_members_path(status: params[:status])
end
def restore
@member = Member.find_by!(slug: params[:slug])
@member.undiscard
redirect_to admin_members_path(status: 'deactivated')
end
def bulk_ban
member_ids = params[:member_ids]
if member_ids.present? && member_ids.is_a?(Array)
members_to_ban = Member.kept.where(id: member_ids)
# Prevent admins from self-banning via bulk ban
members_to_ban = members_to_ban.where.not(id: current_member.id)
members_to_ban.find_each(&:discard)
flash[:notice] = "Selected members have been banned."
else
flash[:alert] = "No members were selected."
end
redirect_to admin_members_path
end
-6
View File
@@ -5,10 +5,4 @@ class AdminController < ApplicationController
def index
authorize! :manage, :all
end
def newsletter
authorize! :manage, :all
@members = Member.confirmed.wants_newsletter.all.paginate(page: params[:page], per_page: 100)
respond_with @members
end
end
+2 -2
View File
@@ -79,7 +79,7 @@ class ApplicationController < ActionController::Base
# profile stuff
:bio, :location, :latitude, :longitude,
# email settings
:show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder)
:show_email, :send_notification_email, :send_planting_reminder, :send_harvest_reminder)
end
devise_parameter_sanitizer.permit(:account_update) do |member|
@@ -91,7 +91,7 @@ class ApplicationController < ActionController::Base
:bio, :location, :latitude, :longitude,
:website_url, :instagram_handle, :facebook_handle, :bluesky_handle, :other_url,
# email settings
:show_email, :newsletter, :send_notification_email, :send_planting_reminder, :send_harvest_reminder,
:show_email, :send_notification_email, :send_planting_reminder, :send_harvest_reminder,
# update password
:current_password)
end
+9 -3
View File
@@ -35,22 +35,28 @@ class CommentsController < ApplicationController
@commentable = @comment.commentable
@comment.author = current_member
@comment.save
respond_with @comment, location: @commentable
respond_with @comment, location: commentable_location(@commentable)
end
def update
@comment.update(body: comment_params['body'])
respond_with @comment, location: @comment.commentable
respond_with @comment, location: commentable_location(@comment.commentable)
end
def destroy
@commentable = @comment.commentable
@comment.destroy
respond_with(@commentable)
respond_with(@commentable, location: commentable_location(@commentable))
end
private
def commentable_location(commentable)
return nil unless commentable
polymorphic_path(commentable, anchor: 'comments')
end
def find_commentable
return unless params[:comment]
+2 -1
View File
@@ -4,9 +4,10 @@
# their jobs, snooze it, or say it is not for them. On the page, or as an overlay
# on their jobs, where each choice is sent without leaving and answered with the
# ideas that are left, the jobs that were added and the member's jobs as they now
# are.
# are. Only while admins have the job ideas feature switched on.
class JobIdeasController < ApplicationController
before_action :authenticate_member!
before_action { raise ActiveRecord::RecordNotFound unless Feature.enabled?(:job_ideas) }
def index
load_ideas
+5
View File
@@ -7,6 +7,7 @@ class JobsController < DataController
if notepad?
load_notepad
return render_groups(:ok) if request.xhr?
else
@jobs = listed_jobs.paginate(page: params[:page], per_page: 30)
end
@@ -78,6 +79,10 @@ class JobsController < DataController
def render_notepad(status)
@owner = current_member
load_notepad
render_groups(status)
end
def render_groups(status)
response.set_header('X-Jobs-Done-This-Week', @done_this_week.to_s)
render partial: 'jobs/notepad_groups', locals: { groups: @job_groups, in_garden: garden.present? }, status:
end
+1 -1
View File
@@ -92,7 +92,7 @@ class MembersController < ApplicationController
}.freeze
def member_params
params.require(:member).permit(:login_name, :tos_agreement, :email, :newsletter, :send_harvest_reminder)
params.require(:member).permit(:login_name, :tos_agreement, :email, :send_harvest_reminder)
end
def member_json_fields
+1 -1
View File
@@ -7,7 +7,7 @@ class NeighboursController < ApplicationController
def show
current_member.feature_seen!(:neighbours)
@neighbours = Neighbours.new(current_member, scope: params[:scope])
@neighbours = Neighbours.new(current_member, scope: params[:scope], limit: Neighbours::FIRST_FEED_EVENTS)
end
# The next small chunk of the feed, for "Show earlier days": its HTML, and
+1 -1
View File
@@ -72,7 +72,7 @@ class RegistrationsController < Devise::RegistrationsController
end
def sign_up_params
params.require(:member).permit(:login_name, :email, :tos_agreement, :newsletter, :password, :password_confirmation)
params.require(:member).permit(:login_name, :email, :tos_agreement, :password, :password_confirmation)
end
def check_captcha
+1 -1
View File
@@ -47,7 +47,7 @@ module ApplicationHelper
end
def build_alert_classes(alert_type = :info)
classes = 'alert alert-dismissible '
classes = 'alert alert-dismissible fade show '
case alert_type.to_sym
when :alert, :danger, :error, :validation_errors
classes += 'alert-danger'
+5 -2
View File
@@ -5,13 +5,16 @@ module EventHelper
(days / 7.0).round
end
# A "job_finished" event is described by jobs/_finished_description.
def event_description(event)
render "#{event.event_type.pluralize}/description", event_model: resolve_model(event)
model = resolve_model(event)
partial = event.event_type.end_with?('_finished') ? 'finished_description' : 'description'
render "#{model.model_name.collection}/#{partial}", event_model: model
rescue ActionView::MissingTemplate
"#{event.event_type.humanize.downcase}d"
end
def resolve_model(event)
event.event_type.classify.constantize.find(event.id)
event.event_type.delete_suffix('_finished').classify.constantize.find(event.id)
end
end
+4
View File
@@ -82,6 +82,10 @@ module IconsHelper
icon('fas', 'note-sticky')
end
def job_finished_icon
finished_icon
end
def post_icon
image_icon 'post'
end
+2 -2
View File
@@ -24,7 +24,7 @@ module JobIdeasHelper
# The sections of the ideas, in order: the kinds of idea each holds.
JOB_IDEA_SECTIONS = {
templates: %i(template), this_time: %i(past_job past_planting), harvest: %i(harvest),
community: %i(community), nearby: %i(nearby)
community: %i(community), nearby: %i(nearby), world: %i(world)
}.freeze
# The shown ideas under their sections, those with none left out.
@@ -38,7 +38,7 @@ module JobIdeasHelper
# What a section says after its heading. Nearby is named for where the member is.
def job_idea_section_note(section, member)
place = member.location.to_s.split(',').first.to_s.strip
return t('job_ideas.section.nearby_note', place:, km: JobIdeas::Nearby::RADIUS_KM) if section == :nearby && place.present?
return t('job_ideas.section.nearby_note', place:) if section == :nearby && place.present?
t("job_ideas.section.#{section}_note")
end
+2
View File
@@ -22,12 +22,14 @@ import './site/navbar_search';
import './site/places';
import './site/planting_alternate_name';
import './site/tab_navigation';
import { initFlashMessages } from './site/flash_messages';
// Photos uploaded to the photo library go from the browser straight to
// storage, not through the app: file fields marked direct_upload.
ActiveStorage.start();
document.addEventListener('DOMContentLoaded', function() {
initFlashMessages();
var popoverTrigger = Array.prototype.slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTrigger.map(function(popoverTrigger2) {
return new bootstrap.Popover(popoverTrigger2);
+1 -2
View File
@@ -13,7 +13,7 @@ const FAILED = {byField: {}, other: ['Something went wrong joining. Please try a
// JSON, shows the server's errors against their fields, and on success goes to
// wherever the server would have sent the new member.
export default function SignUpModal({urls, recaptchaSiteKey, siteName, onClose, onSwitch}) {
const [values, setValues] = useState({login_name: '', email: '', password: '', password_confirmation: '', tos_agreement: false, newsletter: true});
const [values, setValues] = useState({login_name: '', email: '', password: '', password_confirmation: '', tos_agreement: false});
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState({byField: {}, other: []});
const captcha = useRecaptcha(recaptchaSiteKey);
@@ -64,7 +64,6 @@ export default function SignUpModal({urls, recaptchaSiteKey, siteName, onClose,
<div className="row align-items-center">
<div className="col-md-6">
<CheckField id="sign-up-tos" label={<>I agree to the <a href="/policy/tos" target="_blank" rel="noreferrer">Terms of Service</a></>} checked={values.tos_agreement} onChange={set('tos_agreement')} errors={on.tos_agreement} />
<CheckField id="sign-up-newsletter" label={`Subscribe to the ${siteName} newsletter`} checked={values.newsletter} onChange={set('newsletter')} />
</div>
{recaptchaSiteKey ? <div className="col-md-6 mt-3 mt-md-0"><div ref={captcha.ref} /></div> : null}
</div>
@@ -30,11 +30,6 @@ describe('SignUpModal', () => {
expect(screen.getByRole('dialog', {name: 'Join Growstuff'})).toBeInTheDocument();
});
it('offers the newsletter, ticked', () => {
setup();
expect(screen.getByLabelText('Subscribe to the Growstuff newsletter')).toBeChecked();
});
it('does not tick the terms for me', () => {
setup();
expect(screen.getByLabelText(/Terms of Service/)).not.toBeChecked();
+115
View File
@@ -0,0 +1,115 @@
export function initFlashMessages(delay = 2000) {
const flashContainer = document.querySelector('.flash-messages');
if (!flashContainer) return;
const alerts = Array.from(flashContainer.querySelectorAll('.alert'));
if (alerts.length === 0) return;
alerts.forEach((alertEl) => setupAutoDismiss(alertEl, delay));
}
function setupAutoDismiss(alertEl, delay) {
let timer = null;
let interactionStarted = false;
let isHoveredOrFocused = false;
const startTimer = () => {
if (timer) clearTimeout(timer);
if (isHoveredOrFocused) return;
timer = setTimeout(() => {
dismissAlert(alertEl);
}, delay);
};
const pauseTimer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
const handleInteraction = (event) => {
// Ignore interaction events originating inside the alert itself
if (event.target && (event.target instanceof Node) && alertEl.contains(event.target)) return;
if (!interactionStarted) {
interactionStarted = true;
detachInteractionListeners();
}
startTimer();
};
const interactionEvents = ['scroll', 'touchstart', 'mousedown', 'keydown', 'focusin'];
const attachInteractionListeners = () => {
interactionEvents.forEach((type) => {
window.addEventListener(type, handleInteraction, { capture: true, passive: true });
});
};
const detachInteractionListeners = () => {
interactionEvents.forEach((type) => {
window.removeEventListener(type, handleInteraction, { capture: true });
});
};
// Pause on hover or focus within the alert
const onMouseEnter = () => {
isHoveredOrFocused = true;
pauseTimer();
};
const onMouseLeave = () => {
isHoveredOrFocused = false;
if (interactionStarted) {
startTimer();
}
};
const onFocusIn = (e) => {
if (alertEl.contains(e.target)) {
isHoveredOrFocused = true;
pauseTimer();
}
};
const onFocusOut = (e) => {
if (!alertEl.contains(e.relatedTarget)) {
isHoveredOrFocused = false;
if (interactionStarted) {
startTimer();
}
}
};
alertEl.addEventListener('mouseenter', onMouseEnter);
alertEl.addEventListener('mouseleave', onMouseLeave);
alertEl.addEventListener('focusin', onFocusIn);
alertEl.addEventListener('focusout', onFocusOut);
// Clean up if alert is dismissed manually
alertEl.addEventListener('closed.bs.alert', () => {
pauseTimer();
detachInteractionListeners();
});
attachInteractionListeners();
}
function dismissAlert(alertEl) {
if (window.bootstrap && window.bootstrap.Alert) {
const bsAlert = window.bootstrap.Alert.getOrCreateInstance(alertEl);
if (bsAlert) {
bsAlert.close();
return;
}
}
// Fallback if Bootstrap JS object is not loaded
alertEl.classList.remove('show');
setTimeout(() => {
alertEl.remove();
}, 150);
}
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initFlashMessages } from './flash_messages';
describe('initFlashMessages', () => {
let flashContainer;
let alertEl;
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = `
<div class="flash-messages">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<button class="btn-close" type="button" aria-label="Close"></button>
<span>Settings saved!</span>
<input type="text" id="alert-input" />
</div>
</div>
<main>
<button id="outside-btn">Outside</button>
<input type="text" id="outside-input" />
</main>
`;
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;
});
});
+3 -2
View File
@@ -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) {
+38 -5
View File
@@ -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');
+7
View File
@@ -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
-48
View File
@@ -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
+3 -1
View File
@@ -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
+40
View File
@@ -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
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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
+19 -3
View File
@@ -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
+32
View File
@@ -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
+56 -15
View File
@@ -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
+8 -6
View File
@@ -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)
+2 -13
View File
@@ -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,
@@ -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)
+31
View File
@@ -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']
+2 -1
View File
@@ -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'
@@ -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
+130 -21
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
- content_for :title, 'Newsletter subscribers'
%p
- @members.each do |m|
= m.email
%br/
= will_paginate @members
@@ -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'
@@ -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.
@@ -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'
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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|
+3 -3
View File
@@ -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)
+6 -1
View File
@@ -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}
@@ -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}
+5 -4
View File
@@ -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'
+1
View File
@@ -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' }
+13 -3
View File
@@ -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))
+11 -1
View File
@@ -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
+10
View File
@@ -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 }
+4 -2
View File
@@ -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"}
+2 -2
View File
@@ -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
-8
View File
@@ -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'
+6 -4
View File
@@ -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)
-6
View File
@@ -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
+44 -4
View File
@@ -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
+6 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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
+21 -2
View File
@@ -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
+50
View File
@@ -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.
-9
View File
@@ -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=""
+1 -1
View File
@@ -1,3 +1,3 @@
[tools]
ruby = "4.0.7"
ruby = "4.0.9"
node = "24.19.0"
+1 -1
View File
@@ -31,7 +31,7 @@
"sass": "^1.105.2"
},
"engines": {
"node": ">=6.0.0",
"node": "24.x",
"yarn": ">=0.25.2"
},
"devDependencies": {
+3 -12
View File
@@ -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
+3 -3
View File
@@ -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
-4
View File
@@ -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
-22
View File
@@ -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
+35
View File
@@ -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 }
+18
View File
@@ -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:) }
-30
View File
@@ -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) }
@@ -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
@@ -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
@@ -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
+90
View File
@@ -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
@@ -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 }
+9 -1
View File
@@ -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}" }
@@ -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) }
@@ -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
+8
View File
@@ -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) }
+59
View File
@@ -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
+67 -14
View File
@@ -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
+14
View File
@@ -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
+12
View File
@@ -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
+1 -1
View File
@@ -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
+11
View File
@@ -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
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more