Files
growstuff/app/services/member_dashboard.rb
T
Brenda Wallace 3b06325e62 Add friends and neighbours feature with member dismissal functionality
- Implement RecentActivity service to track recent planting, harvesting, and trading events.
- Enhance TimelineService to include garden layouts in event queries.
- Update navigation menu to include a link to friends and neighbours.
- Create member card partial for displaying gardener information.
- Add follow button partial for managing follow actions.
- Refactor members index view to use the new member card partial.
- Introduce neighbours feed view with daily updates and entries.
- Implement entry partial for displaying individual neighbour activities.
- Create layout partial for visualizing garden layouts.
- Add posts section to neighbours view for displaying recent posts.
- Implement reach section to show nearby gardeners.
- Add suggestions section for recommending gardeners to follow.
- Create routes for neighbours functionality and member dismissals.
- Add migrations for member dismissals and garden layout timestamps.
- Write feature and request specs for neighbours functionality.
- Include support for creating located members in tests.
2026-10-13 17:08:18 +13:00

269 lines
8.1 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# frozen_string_literal: true
# What a signed-in member sees on the home page: their own garden's numbers,
# what is ready, and what gardeners at their latitude are doing this month.
#
# How much of it we show depends on how much they have recorded, so #stage
# tells the views whether this is someone's first visit, their first year of
# data, or years of it.
class MemberDashboard
NEARBY_KM = 150
LATITUDE_BAND = 5
Comparison = Struct.new(:crop, :text, keyword_init: true)
attr_reader :member
def initialize(member, today: Time.zone.today)
@member = member
@today = today
end
# :new (nothing planted yet), :novice (first year of data), :veteran.
def stage
@stage ||= if first_planted_on.nil?
:new
elsif first_planted_on > 1.year.ago.to_date
:novice
else
:veteran
end
end
def first_planted_on
return @first_planted_on if defined?(@first_planted_on)
@first_planted_on = member.plantings.minimum(:planted_at)&.to_date
end
def years_growing
return unless first_planted_on
[((@today - first_planted_on) / 365.25).floor, 1].max
end
def located?
member.latitude.present?
end
def gardens
@gardens ||= member.gardens.active.order_by_name.to_a
end
def growing
@growing ||= member.plantings.current.includes({ crop: :parent }, :garden, :photos).order(planted_at: :desc).to_a
end
# Plantings we expect to have something to pick by now.
def ready
@ready ||= growing.select(&:harvest_time?)
end
# What they could record a harvest from: what is growing, with what is
# ready to pick first.
def harvest_choices
ready + (growing - ready)
end
def beds
counts = growing.group_by(&:garden_id)
gardens.map do |garden|
plantings = counts.fetch(garden.id, [])
{ garden:, plantings: plantings.size, ready: plantings.count { |planting| ready.include?(planting) } }
end
end
def plantings_this_year
member.plantings.where(planted_at: year_range(0)).count
end
def plantings_last_year
member.plantings.where(planted_at: year_range(1)).count
end
def harvests_this_year
member.harvests.where(harvested_at: year_range(0)).count
end
def harvests_last_year
member.harvests.where(harvested_at: year_range(1)).count
end
def kilograms_this_year
kilograms(0)
end
def kilograms_last_year
kilograms(1)
end
def seed_varieties
@seed_varieties ||= member.seeds.current.group(:crop_id).count.size
end
# Seed packets grouped by crop, biggest stash first.
def seeds_on_hand(limit = 3)
member.seeds.current.includes(:crop, :photos).group_by(&:crop).sort_by { |_crop, seeds| -seeds.size }.first(limit)
end
# Harvests and kilograms in each month of this year and of last year. Many
# harvests are recorded without a weight, so the counts are the fuller picture.
def harvest_by_month
@harvest_by_month ||= (1..12).map do |month|
{
month:,
harvests: { this_year: monthly_totals[[0, month]][:count], last_year: monthly_totals[[1, month]][:count] },
kilograms: { this_year: monthly_totals[[0, month]][:kilograms], last_year: monthly_totals[[1, month]][:kilograms] }
}
end
end
# Crops they have grown in both this year and last, and how this year went.
def compared_with_last_year(limit = 3)
both = crop_ids_planted_in(0) & crop_ids_planted_in(1)
both.filter_map { |crop_id| sowing_comparison(crop_id) }.first(limit)
end
# The crops they planted in the month either side of this date last year,
# most planted first.
def planted_this_time_last_year(limit = 5)
around = @today - 1.year
member.plantings.where(planted_at: (around - 30.days)..(around + 30.days))
.joins(:crop).group('crops.name').order(Arel.sql('COUNT(*) DESC'), 'crops.name').limit(limit).count.keys
end
# People they follow first, then people nearby.
def activity(limit = 6)
@activity ||= (RecentActivity.events(followed_ids, following: true) + RecentActivity.events(nearby_ids, following: false))
.uniq { |event| [event.member, event.verb, event.subject] }
.first(limit)
end
# The most planted and harvested crops this month and the next two, by
# gardeners in the same band of latitude, in any year.
def at_latitude(months: 3)
return unless located?
Rails.cache.fetch(['at-latitude', band, @today.month, @today.year], expires_in: 6.hours) do
(0...months).map { |offset| month_leaders(((@today.month - 1 + offset) % 12) + 1) }
end
end
# Ten degrees of latitude centred, to the nearest five, on the member.
def band
@band ||= begin
centre = (member.latitude / LATITUDE_BAND).round * LATITUDE_BAND
((centre - LATITUDE_BAND)..(centre + LATITUDE_BAND))
end
end
# Crops gardeners at this latitude are planting this month, most planted
# first, leaving out what the member is already growing or has said is not
# for them.
def suggestions_enabled?
member.suggest_crops
end
def easy_to_sow(limit = 3)
return [] unless suggestions_enabled?
ids = sowing_candidates.pluck(:crop_id) - dismissed_crop_ids - growing.map(&:crop_id)
crops = Crop.approved.where(id: ids).index_by(&:id)
ids.filter_map { |id| crops[id] }.first(limit)
end
def dismissed_count
dismissed_crop_ids.size
end
def suggested_crop
easy_to_sow(1).first
end
def band_label
low, high = [band.begin.abs, band.end.abs].minmax
"#{low}–#{high}°#{member.latitude.negative? ? 'S' : 'N'}"
end
private
def year_range(years_back)
start = @today.beginning_of_year - years_back.years
start..start.end_of_year
end
def kilograms(years_back)
member.harvests.where(harvested_at: year_range(years_back)).sum(:si_weight).to_f.round
end
def monthly_totals
@monthly_totals ||= begin
totals = Hash.new { |hash, key| hash[key] = { count: 0, kilograms: 0.0 } }
[0, 1].each do |back|
harvested = member.harvests.where(harvested_at: year_range(back)).group('EXTRACT(MONTH FROM harvested_at)::int')
harvested.count.each { |month, count| totals[[back, month]][:count] = count }
harvested.sum(:si_weight).each { |month, kilos| totals[[back, month]][:kilograms] = kilos.to_f }
end
totals
end
end
def crop_ids_planted_in(years_back)
member.plantings.where(planted_at: year_range(years_back)).distinct.pluck(:crop_id)
end
def sowing_comparison(crop_id)
this_year = member.plantings.where(crop_id:, planted_at: year_range(0)).minimum(:planted_at)
last_year = member.plantings.where(crop_id:, planted_at: year_range(1)).minimum(:planted_at)
days = this_year.yday - last_year.yday
return if days.zero?
name = Crop.find(crop_id).name.capitalize
Comparison.new(crop: name, text: "sown #{pluralize_days(days.abs)} #{days.negative? ? 'earlier' : 'later'} than last year")
end
def pluralize_days(count)
count == 1 ? '1 day' : "#{count} days"
end
def dismissed_crop_ids
@dismissed_crop_ids ||= member.crop_dismissals.pluck(:crop_id)
end
def sowing_candidates
return [] unless located?
Rails.cache.fetch(['sowing-candidates', band, @today.month, @today.year], expires_in: 6.hours) do
leaders(Planting, :planted_at, @today.month, 40)
end
end
def followed_ids
@followed_ids ||= member.followed.pluck(:id)
end
def nearby_ids
return [] unless located?
@nearby_ids ||= Member.located.near([member.latitude, member.longitude], NEARBY_KM, units: :km)
.where.not(id: member.id).reorder(nil).limit(40).pluck(:id) - followed_ids
end
def month_leaders(month)
{
month:,
planting: leaders(Planting, :planted_at, month),
harvesting: leaders(Harvest, :harvested_at, month)
}
end
def leaders(model, date_column, month, limit = 3)
scope = model.joins(:crop)
scope = model == Planting ? scope.joins(:garden) : scope.joins(planting: :garden)
scope.where(gardens: { latitude: band })
.where("EXTRACT(MONTH FROM #{model.table_name}.#{date_column}) = ?", month)
.group('crops.id', 'crops.name').order(Arel.sql('COUNT(*) DESC')).limit(limit).count
.map { |(crop_id, name), count| { crop_id:, name:, count: } }
end
end