mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-31 03:09:06 -04:00
352 lines
12 KiB
Ruby
352 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# The gardeners around a member: the ones they follow, people they might like
|
|
# to follow, and the ones nearby, and what they have all been doing lately.
|
|
# Nearby widens in rings, close by first and then further out, until there are
|
|
# enough people to show.
|
|
class Neighbours
|
|
include SeasonsHelper
|
|
|
|
SCOPES = %w(auto following nearby anywhere me).freeze
|
|
# Scopes that look at one set of people, so no nearby ones and no suggestions.
|
|
NARROW_SCOPES = %w(following me).freeze
|
|
RADII_KM = [25, 150].freeze
|
|
WORLD_KM = 20_000
|
|
MIN_NEARBY = 3
|
|
FEW_FOLLOWED = 3
|
|
NEARBY_LIMIT = 100
|
|
SUGGESTIONS_SHOWN = 4
|
|
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,
|
|
'transplant' => PlantingHistory, 'job' => Job, 'job_finished' => Job, 'planting_finished' => Planting,
|
|
'seed_finished' => Seed }.freeze
|
|
FEED_CACHE_FOR = 5.minutes
|
|
|
|
# One band of distance: :near, :region or :anywhere. A ring that was
|
|
# searched and held nobody is told apart from one never reached.
|
|
Ring = Struct.new(:key, :people, :searched, keyword_init: true)
|
|
# Why someone is suggested: crops in common, or the people they follow who
|
|
# also follow them.
|
|
Suggestion = Struct.new(:member, :crops, :followed_by, keyword_init: true)
|
|
# One person's day: what they did, newest first, with how they are connected.
|
|
# A photo item is about what it shows: its subject, a planting, harvest, seed
|
|
# or garden.
|
|
Item = Struct.new(:type, :record, :at, :subject, keyword_init: true)
|
|
Entry = Struct.new(:owner, :day, :tag, :items, keyword_init: true) do
|
|
def photos
|
|
items.select { |item| item.type == 'photo' }
|
|
end
|
|
|
|
def others
|
|
items.reject { |item| item.type == 'photo' }
|
|
end
|
|
|
|
# Photos of the same thing, together: [subject, how many]. A photo with
|
|
# nothing to say what it shows is its own group.
|
|
def photo_groups
|
|
photos.group_by { |item| item.subject || item.record }.map { |subject, group| [subject, group.size, group.first.subject.nil?] }
|
|
end
|
|
end
|
|
Day = Struct.new(:day, :updates, keyword_init: true)
|
|
|
|
attr_reader :member, :scope, :offset, :limit
|
|
|
|
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?
|
|
%w(auto following).include?(scope)
|
|
end
|
|
|
|
def shows_suggestions?
|
|
scope == 'auto'
|
|
end
|
|
|
|
def shows_nearby?
|
|
NARROW_SCOPES.exclude?(scope)
|
|
end
|
|
|
|
def located?
|
|
member.latitude.present? && member.longitude.present?
|
|
end
|
|
|
|
# The first part of where they live, as they wrote it: "Wellington" for
|
|
# "Wellington, NZ".
|
|
def place
|
|
member.location.to_s.split(',').first.to_s.strip.presence
|
|
end
|
|
|
|
def followed
|
|
@followed ||= member.followed.confirmed.to_a
|
|
end
|
|
|
|
def few_followed?
|
|
followed.size.between?(1, FEW_FOLLOWED - 1)
|
|
end
|
|
|
|
def suggestions
|
|
@suggestions ||= shows_suggestions? ? hydrate_suggestions(cached('suggestions', 1.hour) { suggest_members }) : []
|
|
end
|
|
|
|
def rings
|
|
@rings ||= shows_nearby? && located? ? hydrate_rings(cached('rings', 6.hours) { build_rings }) : []
|
|
end
|
|
|
|
# Whether nearby had to look beyond the first ring to find enough people.
|
|
def widened?
|
|
rings.many?(&:searched)
|
|
end
|
|
|
|
# Whether there was nobody in the first ring, so everyone shown is further out.
|
|
def nobody_close?
|
|
widened? && rings.first.people.empty?
|
|
end
|
|
|
|
def nearby_members
|
|
rings.flat_map(&:people)
|
|
end
|
|
|
|
# The furthest ring that turned up anyone: what "near you" has come to mean.
|
|
def reach
|
|
rings.select { |ring| ring.people.any? }.last&.key
|
|
end
|
|
|
|
# Who the page is about, for its headings: people they follow and people
|
|
# near them, or only one, or nearby once it has widened to somewhere.
|
|
def audience
|
|
return scope.to_sym if NARROW_SCOPES.include?(scope)
|
|
return (widened? ? :all : :following_and_near) if followed.any? && shows_following?
|
|
|
|
reach || :near
|
|
end
|
|
|
|
# What to say under the title: what they follow and how nearby went.
|
|
def lede
|
|
return :me if scope == 'me'
|
|
|
|
if followed.empty? then nobody_close? ? :widened : :none
|
|
elsif few_followed? then :few
|
|
else :following
|
|
end
|
|
end
|
|
|
|
def posts
|
|
ids = cached('posts', 10.minutes) { Post.where(author_id: feed_owner_ids).recently_active.limit(POSTS_SHOWN).map(&:id) }
|
|
@posts ||= Post.where(id: ids).preload(:author).index_by(&:id).values_at(*ids).compact
|
|
end
|
|
|
|
# Days of what people have lately planted, harvested, saved seeds of,
|
|
# photographed, moved to another garden, planned jobs for, finished growing or
|
|
# with, and rearranged their beds, newest first. One small chunk at a time,
|
|
# from #offset.
|
|
def feed
|
|
build_feed unless defined?(@feed)
|
|
@feed
|
|
end
|
|
|
|
# The hemispheres the feed spans, their own first, so each season marker can
|
|
# name the season in each. Empty when nobody in it has said where they live.
|
|
def hemispheres
|
|
@hemispheres ||= begin
|
|
latitudes = Member.where(id: feed_owner_ids).where.not(latitude: nil).distinct.pluck(Arel.sql('latitude < 0'))
|
|
theirs = latitudes.map { |south| south ? :southern : :northern }
|
|
([hemisphere(member.latitude)] + theirs).compact.uniq
|
|
end
|
|
end
|
|
|
|
def more_feed?
|
|
build_feed unless defined?(@feed)
|
|
@more_feed
|
|
end
|
|
|
|
def next_offset
|
|
offset + limit
|
|
end
|
|
|
|
# Names a cached piece of the page for this member. It changes with who they
|
|
# follow, block and have dismissed, and where they live, so those take
|
|
# effect at once; the rest waits for the entry to expire.
|
|
def cache_key(*parts)
|
|
['neighbours', member.id, scope, member.latitude, member.longitude,
|
|
Digest::SHA1.hexdigest([followed_ids, blocked_ids, dismissed_ids].map { |ids| ids.sort.join(',') }.join('|')), *parts]
|
|
end
|
|
|
|
private
|
|
|
|
def cached(name, expires_in, &)
|
|
Rails.cache.fetch(cache_key(name), expires_in:, &)
|
|
end
|
|
|
|
def followed_ids
|
|
@followed_ids ||= member.follows.pluck(:followed_id)
|
|
end
|
|
|
|
def blocked_ids
|
|
@blocked_ids ||= member.blocks.pluck(:blocked_id)
|
|
end
|
|
|
|
def dismissed_ids
|
|
@dismissed_ids ||= member.member_dismissals.pluck(:dismissed_id)
|
|
end
|
|
|
|
def left_out_ids
|
|
[member.id] + followed_ids + blocked_ids
|
|
end
|
|
|
|
def feed_owner_ids
|
|
return [member.id] if scope == 'me'
|
|
|
|
@feed_owner_ids ||= (shows_following? ? followed_ids : []) + nearby_members.map(&:id)
|
|
end
|
|
|
|
def within(radius)
|
|
Member.confirmed.located.where.not(id: left_out_ids).near([member.latitude, member.longitude], radius, units: :km)
|
|
end
|
|
|
|
# Rings as plain data, so they can be cached: who is in each, as ids.
|
|
def build_rings
|
|
radii = scope == 'anywhere' ? [WORLD_KM] : RADII_KM + [WORLD_KM]
|
|
keys = scope == 'anywhere' ? %i(anywhere) : %i(near region anywhere)
|
|
found = 0
|
|
inner_ids = []
|
|
radii.zip(keys).map do |radius, key|
|
|
next { key:, ids: [], searched: false } if found >= MIN_NEARBY && scope != 'anywhere'
|
|
|
|
ids = within(radius).where.not(id: inner_ids).limit(NEARBY_LIMIT).map(&:id)
|
|
found += ids.size
|
|
inner_ids = within(radius).reorder(nil).pluck(:id) unless radius == WORLD_KM
|
|
{ key:, ids:, searched: true }
|
|
end
|
|
end
|
|
|
|
def hydrate_rings(rings)
|
|
people = Member.where(id: rings.flat_map { |ring| ring[:ids] }).index_by(&:id)
|
|
rings.map do |ring|
|
|
Ring.new(key: ring[:key], searched: ring[:searched], people: people.values_at(*ring[:ids]).compact)
|
|
end
|
|
end
|
|
|
|
# People growing what they are, most in common first, then whoever was
|
|
# planting most recently; then people their friends follow. As ids and names,
|
|
# so they can be cached.
|
|
def suggest_members
|
|
(shared_crops + followed_by_friends).uniq { |suggestion| suggestion[:id] }.first(SUGGESTIONS_SHOWN)
|
|
end
|
|
|
|
def shared_crops
|
|
mine = member.plantings.current.distinct.pluck(:crop_id)
|
|
return [] if mine.empty?
|
|
|
|
Planting.current.joins(:crop).where(crop_id: mine).where.not(owner_id: left_out_ids + dismissed_ids)
|
|
.group(:owner_id)
|
|
.order(Arel.sql('COUNT(DISTINCT plantings.crop_id) DESC'), Arel.sql('MAX(plantings.planted_at) DESC'))
|
|
.limit(SUGGESTIONS_SHOWN)
|
|
.pluck(:owner_id, Arel.sql('ARRAY_AGG(DISTINCT crops.name)'))
|
|
.map { |id, crops| { id:, crops: crops.first(2), followed_by: [] } }
|
|
end
|
|
|
|
def followed_by_friends
|
|
return [] if followed_ids.empty?
|
|
|
|
Follow.where(follower_id: followed_ids).where.not(followed_id: left_out_ids + dismissed_ids)
|
|
.group(:followed_id).order(Arel.sql('COUNT(*) DESC')).limit(SUGGESTIONS_SHOWN)
|
|
.pluck(:followed_id, Arel.sql('ARRAY_AGG(follower_id)'))
|
|
.map { |id, follower_ids| { id:, crops: [], followed_by: follower_ids.first(2) } }
|
|
end
|
|
|
|
def hydrate_suggestions(suggestions)
|
|
people = Member.confirmed.where(id: suggestions.flat_map { |suggestion| [suggestion[:id], *suggestion[:followed_by]] }).index_by(&:id)
|
|
suggestions.filter_map do |suggestion|
|
|
next unless people[suggestion[:id]]
|
|
|
|
Suggestion.new(member: people[suggestion[:id]], crops: suggestion[:crops],
|
|
followed_by: people.values_at(*suggestion[:followed_by]).compact)
|
|
end
|
|
end
|
|
|
|
def build_feed
|
|
rows = feed_rows
|
|
@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(limit + 1).to_a
|
|
end
|
|
|
|
def days_of(rows)
|
|
records = load_records(rows)
|
|
owners = Member.where(id: rows.map(&:owner_id)).index_by(&:id)
|
|
entries = rows.group_by { |row| [day_of(row.event_at), row.owner_id] }.map do |(day, owner_id), group|
|
|
items = group.filter_map { |row| item_for(row, records) }
|
|
Entry.new(owner: owners[owner_id], day:, tag: tag_for(owner_id), items:) if owners[owner_id] && items.any?
|
|
end
|
|
entries.compact.group_by(&:day).map { |day, updates| Day.new(day:, updates:) }
|
|
end
|
|
|
|
def load_records(rows)
|
|
records = rows.group_by(&:event_type).to_h do |type, group|
|
|
scope = EVENT_MODELS.fetch(type).where(id: group.map(&:id))
|
|
scope = scope.includes(:crop) if %w(planting harvest seed planting_finished seed_finished).include?(type)
|
|
scope = scope.includes(:garden, planting: [:crop, :garden, { histories: :garden }]) if type == 'transplant'
|
|
[type, scope.index_by(&:id)]
|
|
end
|
|
records['photo_subjects'] = photo_subjects(records.fetch('photo', {}).keys)
|
|
records
|
|
end
|
|
|
|
# What each photo is of: the first of a planting, harvest, seed or garden it
|
|
# is attached to.
|
|
def photo_subjects(photo_ids)
|
|
return {} if photo_ids.empty?
|
|
|
|
links = PhotoAssociation.where(photo_id: photo_ids, photographable_type: PHOTO_SUBJECTS).pluck(:photo_id, :photographable_type,
|
|
:photographable_id)
|
|
subjects = links.group_by { |_photo_id, type, _id| type }.to_h do |type, group|
|
|
model = type.constantize
|
|
model = model.includes(:crop) unless %w(Garden Job).include?(type)
|
|
[type, model.where(id: group.map(&:last)).index_by(&:id)]
|
|
end
|
|
links.sort_by { |_photo_id, type, _id| PHOTO_SUBJECTS.index(type) }.each_with_object({}) do |(photo_id, type, id), found|
|
|
found[photo_id] ||= subjects[type][id]
|
|
end
|
|
end
|
|
|
|
def item_for(row, records)
|
|
record = records.dig(row.event_type, row.id)
|
|
return unless record
|
|
|
|
at = row.event_at.in_time_zone unless midnight?(row.event_at)
|
|
Item.new(type: row.event_type, record:, at:, subject: records['photo_subjects'][row.id])
|
|
end
|
|
|
|
# Dates come through the timeline as midnight, in whatever zone the database
|
|
# connection reads times in, so they have no time of day.
|
|
def midnight?(time)
|
|
!time.respond_to?(:hour) || time.seconds_since_midnight.zero?
|
|
end
|
|
|
|
def day_of(time)
|
|
return time.to_date unless time.respond_to?(:hour)
|
|
|
|
midnight?(time) ? time.to_date : time.in_time_zone.to_date
|
|
end
|
|
|
|
def tag_for(owner_id)
|
|
return :following if followed_ids.include?(owner_id)
|
|
|
|
:near if rings.first&.people&.any? { |person| person.id == owner_id }
|
|
end
|
|
end
|