Refactor Job Ideas feature: replace dismiss! with hide! method, update job count logic, and enhance UI components for better visibility and layout

This commit is contained in:
Brenda Wallace committed 2026-10-23 21:04:21 +13:00
1 parent 7a4bff150e
commit b22fa68bdf
24 files changed
+144 -115

No files matched your search

+9 -3
View File
@@ -27,7 +27,7 @@ class JobIdeasController < ApplicationController
key = params.require(:key)
length = params[:length].to_s
snoozed = JobIdeaDismissal::SNOOZES.key?(length)
snoozed ? JobIdeaDismissal.snooze!(current_member, key, length) : JobIdeaDismissal.dismiss!(current_member, key)
snoozed ? JobIdeaDismissal.snooze!(current_member, key, length) : JobIdeaDismissal.hide!(current_member, key)
changed([], t("job_ideas.hidden.#{snoozed ? length : 'dismissed'}"))
end
@@ -57,17 +57,23 @@ class JobIdeasController < ApplicationController
def changed(added, notice)
return redirect_to(job_ideas_path(skip: skipped), notice:) unless request.xhr?
JobIdeas.forget_count(current_member)
@job_ideas = nil
load_ideas
render json: overlay_json(added)
end
# The member's jobs, as they now are, to show those that were added.
def jobs_groups
groups = JobsNotepad.new(current_member).groups
render_to_string(partial: 'jobs/notepad_groups', formats: [:html], locals: { groups:, in_garden: false })
end
def overlay_json(added)
notepad = JobsNotepad.new(current_member)
count = @ideas.size + @more
{
list: render_to_string(partial: 'job_ideas/list', formats: [:html]),
groups: render_to_string(partial: 'jobs/notepad_groups', formats: [:html], locals: { groups: notepad.groups, in_garden: false }),
groups: (jobs_groups if added.any?),
count:, banner: t('job_ideas.banner', count:), added_label: t('job_ideas.added_to_jobs', count: added.size),
added: added.map { |job| { id: job.id, name: job.name, due: (l(job.due_date, format: '%-d %b') if job.due_date) } }
}
+1 -10
View File
@@ -1,15 +1,6 @@
# frozen_string_literal: true
module JobIdeasHelper
# How many ideas a member has, for the count beside the link to them. It looks
# at other people's jobs, so it is kept for a few minutes, and changes at once
# when they hide an idea.
def job_ideas_count(member)
hidden = member.job_idea_dismissals.maximum(:updated_at)
changed = member.jobs.maximum(:updated_at)
Rails.cache.fetch(['job_ideas_count', member.id, hidden, changed], expires_in: 10.minutes) { JobIdeas.new(member).ideas.size }
end
# What an idea would add, as the member would read it.
def job_idea_title(idea)
case idea.kind
@@ -23,7 +14,7 @@ module JobIdeasHelper
def job_idea_reason(idea)
day = idea.on && l(idea.on, format: '%-d %b')
case idea.kind
when :template then t('job_ideas.reason.template', count: idea.job_count, steps: job_idea_steps(idea), crop: idea.crop.name, day:)
when :template then t('job_ideas.reason.template', count: idea.schedule.size, steps: job_idea_steps(idea), crop: idea.crop.name, day:)
when :past_job, :past_planting then t("job_ideas.reason.#{idea.kind}", count: idea.years, day:)
when :harvest then t("job_ideas.reason.harvest_#{idea.period}", day:)
else community_reason(idea, day)
+24 -10
View File
@@ -84,7 +84,7 @@ document.addEventListener('DOMContentLoaded', function () {
// arrive.
const showJobs = function (data) {
const groups = document.getElementById('notepad-groups');
if (!groups) return;
if (!groups || !data.groups) return;
const added = new Set(data.added.map(function (job) { return String(job.id); }));
groups.innerHTML = data.groups;
groups.querySelectorAll('.notepad-job').forEach(function (row) {
@@ -92,25 +92,39 @@ document.addEventListener('DOMContentLoaded', function () {
});
};
const choose = function (form) {
const row = form.closest('.idea');
if (row) row.classList.add('idea-working');
// Sends the choice. Rejects only if it could not be sent, or the answer was no
// good: then it is safe to send it again the ordinary way.
const send = function (form) {
return request(form.action, {method: 'POST', body: new FormData(form), headers: {Accept: 'application/json'}})
.then(ok)
.then(function (response) { return response.json(); })
.then(function (data) {
.then(function (response) { return response.json(); });
};
// Shows what the server did. If that goes wrong the choice was made all the
// same, so the page is loaded again to show how things stand.
const show = function (form, data) {
const row = form.closest('.idea');
return Promise.resolve()
.then(function () {
showCount(data);
showJobs(data);
if (data.added.length && row) {
row.replaceChildren(addedBlock(data));
row.classList.remove('idea-working');
row.classList.add('idea-confirmed');
return wait(Math.min(2600, 1100 + data.added.length * 140)).then(function () { return data; });
return wait(Math.min(2600, 1100 + data.added.length * 140));
}
if (row) row.classList.add('idea-leaving');
return wait(350).then(function () { return data; });
return wait(350);
})
.then(function (data) { showList(data.list); });
.then(function () { showList(data.list); })
.catch(function () { window.location.reload(); });
};
const choose = function (form) {
const row = form.closest('.idea');
if (row) row.classList.add('idea-working');
return send(form).then(function (data) { return show(form, data); }, function () { form.submit(); });
};
document.querySelectorAll('[data-job-ideas-open]').forEach(function (link) {
@@ -132,6 +146,6 @@ document.addEventListener('DOMContentLoaded', function () {
const form = event.target.closest('.idea-form');
if (!form) return;
event.preventDefault();
choose(form).catch(function () { form.submit(); });
choose(form);
});
});
+6
View File
@@ -146,6 +146,12 @@ describe('choosing an idea', () => {
expect(document.querySelector('.notepad-ideas').hidden).toBe(true);
});
it('does not send the choice again once the server has answered', async () => {
await choose();
const form = body().querySelector('.idea-form');
expect(form === null || form.submit === HTMLFormElement.prototype.submit).toBe(true);
});
it('sends the choice the ordinary way if it could not be sent', async () => {
fetchMock.mockResolvedValueOnce(html(idea('a', 'Snip'))).mockRejectedValueOnce(new Error('offline'));
await start();
-4
View File
@@ -20,10 +20,6 @@ class JobIdeaDismissal < ApplicationRecord
dismissal
end
def self.dismiss!(member, key)
hide!(member, key)
end
# For one of SNOOZES, named.
def self.snooze!(member, key, length)
hide!(member, key, SNOOZES.fetch(length))
+21 -8
View File
@@ -8,19 +8,32 @@ class JobIdeas
# How many ideas are suggested at a time.
SHOWN = 5
# kind is where the idea came from. job_count is how many jobs a template would
# add, and schedule those jobs, with their days; on is the latest day it was done or planted at this time of year, and
# years how many years it was. From other gardeners, gardeners is how many did
# it, of how many grow the crop, now or in earlier years, as period says. For
# a harvest, on is the day it was last harvested from, or if never, the day it
# was expected to be ready, as period says.
Idea = Struct.new(:kind, :key, :name, :crop, :planting, :template, :schedule, :job_count, :on, :years, :gardeners, :of, :period,
# Where an idea came from is its kind. The rest is what the kind has to say:
# - planting and template: the planting it is for; a template idea also has the
# template and its schedule, the jobs it would add with their days
# - on: the latest day it was done or planted at this time of year, and years how
# many years; for a harvest, the day it was last harvested from or, if never,
# expected to be ready, as period says
# - gardeners: from other gardeners, how many did it, of how many grow the crop,
# now or in earlier years, as period says
Idea = Struct.new(:kind, :key, :name, :crop, :planting, :template, :schedule, :on, :years, :gardeners, :of, :period,
keyword_init: true)
# 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
# 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
# an idea.
def self.count_for(member)
Rails.cache.fetch(['job_ideas_count', member.id], expires_in: 10.minutes) { new(member).ideas.size }
end
def self.forget_count(member)
Rails.cache.delete(['job_ideas_count', member.id])
end
def initialize(member, today: Time.zone.today, skip: [])
@member = member
@today = today
@@ -56,6 +69,6 @@ class JobIdeas
end
def hidden_keys
@hidden_keys ||= @member.job_idea_dismissals.hiding.pluck(:key)
@hidden_keys ||= @member.job_idea_dismissals.hiding.pluck(:key).to_set
end
end
+1 -1
View File
@@ -27,7 +27,7 @@ class JobIdeas
# template would come back for the next planting of the crop.
def add_template
jobs = ApplyCropJobTemplate.new(@idea.template, @idea.planting, today: @today).call
JobIdeaDismissal.dismiss!(@member, @idea.key)
JobIdeaDismissal.hide!(@member, @idea.key)
jobs
end
+9 -7
View File
@@ -5,12 +5,16 @@
# same time of year; nobody if the member has not said where they are.
class JobIdeas
class Community < OthersJobs
include SeasonsHelper
private
def owner_ids
return [] unless @member.latitude
Member.confirmed.located.where(@member.latitude.negative? ? 'latitude < 0' : 'latitude >= 0').pluck(:id)
@owner_ids ||= case hemisphere(@member.latitude)
when :southern then Member.confirmed.located.where('latitude < 0').pluck(:id)
when :northern then Member.confirmed.located.where('latitude >= 0').pluck(:id)
else []
end
end
def crop_ids
@@ -21,10 +25,8 @@ class JobIdeas
:community
end
def growing(crop)
return unless crop
growers[crop.id] || 0
def growing(crop_id)
growers[crop_id] || 0 if crop_id
end
def growers
+5 -3
View File
@@ -14,13 +14,15 @@ class JobIdeas
end
def ideas
plantings.filter_map { |planting| idea_for(planting) }
found = plantings.to_a
@last_harvests = ::Harvest.where(planting_id: found.map(&:id)).group(:planting_id).maximum(:harvested_at)
found.filter_map { |planting| idea_for(planting) }
end
private
def plantings
@member.plantings.active.annual.where.not(planted_at: nil).preload(:crop, :harvests)
@member.plantings.active.annual.where.not(planted_at: nil).preload(:crop)
end
def idea_for(planting)
@@ -42,7 +44,7 @@ class JobIdeas
end
def last_harvest(planting)
planting.harvests.filter_map(&:harvested_at).max&.to_date
@last_harvests[planting.id]&.to_date
end
def expected_on(planting)
+5 -3
View File
@@ -9,9 +9,11 @@ class JobIdeas
private
def owner_ids
return [] unless @member.latitude && @member.longitude
Member.confirmed.located.near([@member.latitude, @member.longitude], RADIUS_KM, units: :km).reorder(nil).pluck(:id)
@owner_ids ||= if @member.latitude && @member.longitude
Member.confirmed.located.near([@member.latitude, @member.longitude], RADIUS_KM, units: :km).reorder(nil).pluck(:id)
else
[]
end
end
def kind
+21 -35
View File
@@ -13,7 +13,6 @@ class JobIdeas
MIN_GARDENERS = 3
MAX_IDEAS = 5
CROP_ID = "COALESCE(CASE WHEN jobs.jobbable_type = 'Crop' THEN jobs.jobbable_id END, plantings.crop_id, seeds.crop_id)"
WITHIN = '(jobs.finished_at BETWEEN ? AND ? OR (jobs.finished_at IS NULL AND jobs.due_date BETWEEN ? AND ?))'
Row = Struct.new(:name, :owner_id, :crop_id, :on)
def initialize(member, today)
@@ -24,8 +23,9 @@ class JobIdeas
def ideas
return [] if owner_ids&.empty? || crop_ids&.empty?
crops = Crop.where(id: grouped.keys.filter_map(&:last)).index_by(&:id)
grouped.filter_map { |(_name, crop_id), rows| idea_for(rows, crops[crop_id]) }.max_by(MAX_IDEAS, &:gardeners)
found = candidates.max_by(MAX_IDEAS) { |candidate| candidate[:gardeners] }
crops = Crop.where(id: found.filter_map { |candidate| candidate[:crop_id] }).index_by(&:id)
found.map { |candidate| idea_for(candidate, crops[candidate[:crop_id]]) }
end
private
@@ -46,57 +46,43 @@ class JobIdeas
# How many gardeners grow the crop, to say how many of them did it. Only
# worth saying when the jobs are limited to those growing the crop.
def growing(_crop)
def growing(_crop_id)
nil
end
def idea_for(rows, crop)
return if open_names.include?(rows.first.name.downcase)
# What each job done by enough gardeners comes to, before the crops are loaded.
def candidates
rows.group_by { |row| [row.name.downcase, row.crop_id] }.filter_map do |(name, crop_id), group|
next if open_names.include?(name)
now, earlier = rows.partition { |row| row.on > @today - WINDOW }
owners = [now, earlier].map { |group| group.map(&:owner_id).uniq.size }
period, gardeners = owners.first >= MIN_GARDENERS ? [:now, owners.first] : [:earlier, owners.last]
return if gardeners < MIN_GARDENERS
now, earlier = group.partition { |row| row.on > @today - WINDOW }
owners = [now, earlier].map { |found| found.map(&:owner_id).uniq.size }
period, gardeners = owners.first >= MIN_GARDENERS ? [:now, owners.first] : [:earlier, owners.last]
next if gardeners < MIN_GARDENERS
Idea.new(kind:, key: "#{kind}:#{rows.first.name.downcase}:#{crop&.id}", name: rows.first.name, crop:, gardeners:,
period:, of: growing(crop), on: rows.map(&:on).max)
{ name: group.first.name, crop_id:, period:, gardeners:, on: group.map(&:on).max }
end
end
def grouped
@grouped ||= rows.group_by { |row| [row.name.downcase, row.crop_id] }
def idea_for(candidate, crop)
Idea.new(kind:, key: "#{kind}:#{candidate[:name].downcase}:#{crop&.id}", name: candidate[:name], crop:,
gardeners: candidate[:gardeners], period: candidate[:period], of: growing(crop&.id), on: candidate[:on])
end
def rows
jobs = Job.where(finished: true, crop_job_step_id: nil).where.not(owner_id: left_out_ids)
jobs = jobs.where(owner_id: owner_ids) if owner_ids
jobs = jobs.where("#{CROP_ID} IN (?)", crop_ids) if crop_ids
jobs.joins("LEFT JOIN plantings ON jobs.jobbable_type = 'Planting' AND plantings.id = jobs.jobbable_id")
.joins("LEFT JOIN seeds ON jobs.jobbable_type = 'Seed' AND seeds.id = jobs.jobbable_id")
.where(in_windows, *window_values)
finished_in(jobs.joins("LEFT JOIN plantings ON jobs.jobbable_type = 'Planting' AND plantings.id = jobs.jobbable_id")
.joins("LEFT JOIN seeds ON jobs.jobbable_type = 'Seed' AND seeds.id = jobs.jobbable_id"),
[(@today - WINDOW)..@today] + past_windows)
.pluck('jobs.name', 'jobs.owner_id', 'jobs.finished_at', 'jobs.due_date', Arel.sql(CROP_ID))
.map { |name, owner_id, finished_at, due_date, crop_id| Row.new(name, owner_id, crop_id, finished_at&.to_date || due_date) }
end
# Now, and the same fortnight in each earlier year.
def windows
[(@today - WINDOW)..@today] + past_windows
end
def in_windows
windows.map { WITHIN }.join(' OR ')
end
def window_values
windows.flat_map { |window| [window.first.beginning_of_day, window.last.end_of_day, window.first, window.last] }
end
# Themselves, and the people they have blocked.
def left_out_ids
[@member.id] + @member.blocks.pluck(:blocked_id)
end
def open_names
@open_names ||= @member.jobs.active.pluck(:name).map(&:downcase)
@left_out_ids ||= [@member.id] + @member.blocks.pluck(:blocked_id)
end
end
end
+7 -10
View File
@@ -27,18 +27,15 @@ class JobIdeas
# Each job with the day it was done. A job finished before the time was
# recorded was done on the day it was due.
def done_jobs
jobs = past_windows.map { |window| finished_in(window) }.reduce(:or)
jobs.includes(:jobbable).map { |job| [job, job.finished_at&.to_date || job.due_date] }
jobs = finished_in(@member.jobs.where(finished: true, crop_job_step_id: nil), past_windows).includes(:jobbable).to_a
preload_crops(jobs)
jobs.map { |job| [job, job.finished_at&.to_date || job.due_date] }
end
def finished_in(window)
@member.jobs.where(finished: true, crop_job_step_id: nil)
.where('jobs.finished_at BETWEEN ? AND ? OR (jobs.finished_at IS NULL AND jobs.due_date BETWEEN ? AND ?)',
window.first.beginning_of_day, window.last.end_of_day, window.first, window.last)
end
def open_names
@open_names ||= @member.jobs.active.pluck(:name).map(&:downcase)
# The crop of every planting and seed a job is about, in one go.
def preload_crops(jobs)
about = jobs.filter_map(&:jobbable).grep_v(Crop)
ActiveRecord::Associations::Preloader.new(records: about, associations: :crop).call
end
def key_for(job)
+2 -1
View File
@@ -17,8 +17,9 @@ class JobIdeas
next if planted_since.include?(crop_id) || to_plant.include?(crop_id)
latest = plantings.max_by(&:planted_at)
years = plantings.map { |planting| planting.planted_at.year }.uniq.size
Idea.new(kind: :past_planting, key: "past_planting:#{crop_id}", name: latest.crop.name, crop: latest.crop,
planting: latest, on: latest.planted_at, years: plantings.map { |planting| planting.planted_at.year }.uniq.size)
on: latest.planted_at, years:)
end
end
+18 -1
View File
@@ -2,10 +2,15 @@
class JobIdeas
# The time of year a source looks back to: the fortnight starting on today's day
# in each of the years before, as far back as MAX_YEARS.
# in each of the years before, as far back as MAX_YEARS. Also what the sources
# share about jobs, which are finished in a window and which the member already
# has open.
module PastYears
WINDOW = 14.days
MAX_YEARS = 5
# Done in the window by the time it was crossed off or, if that was not
# recorded, the day it was due.
FINISHED_IN = '(jobs.finished_at BETWEEN ? AND ? OR (jobs.finished_at IS NULL AND jobs.due_date BETWEEN ? AND ?))'
private
@@ -13,5 +18,17 @@ class JobIdeas
def past_windows
(1..MAX_YEARS).map { |years| (@today - years.years)..(@today - years.years + WINDOW) }
end
# The finished jobs in `jobs` that were done in any of the windows.
def finished_in(jobs, windows)
windows.map do |window|
jobs.where(FINISHED_IN, window.first.beginning_of_day, window.last.end_of_day, window.first, window.last)
end.reduce(:or)
end
# The names of the jobs the member has open, lower case.
def open_names
@open_names ||= @member.jobs.active.pluck(:name).to_set(&:downcase)
end
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class JobIdeas
by_template.map do |template, found|
planting, entries = found.max_by { |planting, _entries| [planting.planted_at, planting.id] }
Idea.new(kind: :template, key: "template:#{template.id}", name: template.name, crop: template.crop, planting:, template:,
schedule: entries, job_count: entries.size, on: planting.planted_at.to_date)
schedule: entries, on: planting.planted_at.to_date)
end
end
+1 -1
View File
@@ -27,7 +27,7 @@
%span.idea-preview-day= l(entry.date, format: '%-d %b')
= entry.step.name
.idea-actions
- add = idea.kind == :template ? t('job_ideas.add_all', count: idea.job_count) : t('job_ideas.add')
- add = idea.kind == :template ? t('job_ideas.add_all', count: idea.schedule.size) : t('job_ideas.add')
= button_to add, job_ideas_path(skip:), params: { key: idea.key }, class: 'idea-add', form_class: 'idea-form'
.idea-later
- dismiss = { params: { key: idea.key }, class: 'idea-link', form_class: 'idea-form' }
+1 -1
View File
@@ -18,7 +18,7 @@
.col-xl-6.mb-4.order-first.order-xl-last
.notepad.job-ideas
.notepad-title
%h2= t('job_ideas.card_title')
%h2= t('job_ideas.title')
= render 'job_ideas/list'
.col-xl-6.mb-4
= render 'jobs/notepad', owner: current_member, groups: @job_groups, done_this_week: @done_this_week,
+5
View File
@@ -0,0 +1,5 @@
-# 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'
+1 -1
View File
@@ -5,7 +5,7 @@
.modal-dialog.modal-dialog-scrollable.modal-lg
.modal-content
.modal-header
%h2.modal-title#job-ideas-title= t('job_ideas.card_title')
%h2.modal-title#job-ideas-title= t('job_ideas.title')
%button.btn-close{ type: 'button', data: { bs_dismiss: 'modal' }, 'aria-label': t('job_ideas.done') }
.modal-body.p-0
.notepad.job-ideas#job-ideas-body{ 'aria-live': 'polite' }
+1 -4
View File
@@ -25,10 +25,7 @@
%span.notepad-add-saving-text
%p.notepad-add-failed.text-danger.mx-3{ id: 'notepad-add-failed', role: 'alert', hidden: true }= t('jobs.pad.add_failed')
%p.notepad-note= t('jobs.pad.public_note') + ' ' + t('jobs.pad.link_hint')
- banner = !garden && local_assigns.fetch(:ideas_banner, true)
- ideas = job_ideas_count(owner) if banner
= render 'jobs/ideas_banner', count: ideas if ideas&.positive?
= render 'jobs/ideas_modal' if banner
= render 'jobs/ideas', owner: owner unless garden || !local_assigns.fetch(:ideas_banner, true)
#notepad-groups{ data: { celebrations: job_celebrations.to_json, garden_id: garden&.id } }= render 'jobs/notepad_groups', groups:, in_garden: garden.present?
.notepad-snail
-2
View File
@@ -665,7 +665,6 @@ en:
unknown: Unknown
job_ideas:
title: Ideas for now
card_title: Ideas for now
banner:
one: We have 1 suggested job for you this week
other: We have %{count} suggested jobs for you this week
@@ -691,7 +690,6 @@ en:
nearby: Near you
nearby_note: "%{place} and %{km} km around"
none: Nothing to suggest right now. Check back as the season changes.
back: Back to your jobs
add: "+ Add"
add_all: Add all %{count}
preview:
+3 -3
View File
@@ -24,8 +24,8 @@ describe JobIdeaDismissal do
end
end
describe '.dismiss!' do
before { described_class.dismiss!(member, key) }
describe '.hide!' do
before { described_class.hide!(member, key) }
it 'hides the idea' do
expect(hidden_keys).to eq [key]
@@ -61,7 +61,7 @@ describe JobIdeaDismissal do
end
it 'does not keep two for the same idea' do
described_class.dismiss!(member, key)
described_class.hide!(member, key)
expect(member.job_idea_dismissals.count).to eq 1
end
end
@@ -19,10 +19,6 @@ describe JobIdeas::PastPlantings do
expect(ideas.first.on).to eq planted_at
end
it 'is about the planting it was last done' do
expect(ideas.first.planting).to eq planting
end
it 'keys the idea by crop' do
expect(ideas.first.key).to eq "past_planting:#{crop.id}"
end
+2 -2
View File
@@ -25,7 +25,7 @@ describe JobIdeas::Templates do
end
it 'counts the jobs it would add' do
expect(ideas.first.job_count).to eq 2
expect(ideas.first.schedule.size).to eq 2
end
it 'keys the idea by template' do
@@ -52,7 +52,7 @@ describe JobIdeas::Templates do
end
it 'counts the jobs for that planting only' do
expect(ideas.first.job_count).to eq 2
expect(ideas.first.schedule.size).to eq 2
end
end