From b671b4ebff495fe98615b38fcbf24bd8a87610ca Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Sat, 31 Oct 2026 17:24:30 +1300 Subject: [PATCH] charts of temp and rain --- app/assets/stylesheets/_plantings.scss | 24 +++ app/controllers/plantings_controller.rb | 1 + app/helpers/planting_weather_helper.rb | 126 +++++++++++++ app/helpers/plantings_helper.rb | 232 ++++++++++++++---------- app/models/planting.rb | 13 ++ app/services/weather_updater.rb | 8 +- app/views/plantings/_weather.html.haml | 6 +- app/views/plantings/show.html.haml | 2 +- config/locales/en.yml | 1 + spec/helpers/plantings_helper_spec.rb | 18 ++ spec/models/planting_spec.rb | 30 +++ spec/services/weather_updater_spec.rb | 24 ++- 12 files changed, 381 insertions(+), 104 deletions(-) create mode 100644 app/helpers/planting_weather_helper.rb diff --git a/app/assets/stylesheets/_plantings.scss b/app/assets/stylesheets/_plantings.scss index f2106309a..928f8442b 100755 --- a/app/assets/stylesheets/_plantings.scss +++ b/app/assets/stylesheets/_plantings.scss @@ -566,6 +566,30 @@ fill: $blue; } + // A prediction, from the same days last year: dotted, and paler. Lines are dots; bars are hollow with a dotted outline. + .weather-expected { + opacity: 0.7; + } + + .weather-temperature polyline.weather-expected { + stroke-dasharray: 0.5 6; + stroke-linecap: round; + stroke-width: 3; + } + + .weather-rain rect.weather-expected { + fill: $blue; + fill-opacity: 0.15; + stroke: $blue; + stroke-dasharray: 2 2; + stroke-width: 1; + vector-effect: non-scaling-stroke; + } + + .weather-expected-note { + margin-bottom: 0.25rem; + } + // Seasons: a faint tint behind the data, and the same colours in the strip of names under the charts. .season-spring { fill: $green; diff --git a/app/controllers/plantings_controller.rb b/app/controllers/plantings_controller.rb index 9dee01dc6..e2d8cd5f4 100644 --- a/app/controllers/plantings_controller.rb +++ b/app/controllers/plantings_controller.rb @@ -28,6 +28,7 @@ class PlantingsController < DataController @current_jobs = @planting.jobs.active.includes(:owner).order(created_at: :desc) @finished_jobs = @planting.jobs.finished.includes(:owner).order(created_at: :desc) @weather = @planting.weather_readings.to_a + @expected_weather = @planting.expected_weather_readings @matching_seeds = matching_seeds @crop = @planting.crop diff --git a/app/helpers/planting_weather_helper.rb b/app/helpers/planting_weather_helper.rb new file mode 100644 index 000000000..976d87d98 --- /dev/null +++ b/app/helpers/planting_weather_helper.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# The weather a planting grew through, and is expected to: charts and figures for plantings#show. +module PlantingWeatherHelper + include SeasonsHelper + + WEATHER_CHART_WIDTH = 600 + WEATHER_CHART_HEIGHT = 80 + + # The headline figures for a run of weather readings, as text, leaving out any we have no data for. + def weather_summary(readings) + rain = readings.filter_map(&:rainfall_mm) + hottest = readings.filter_map(&:temp_max_c).max + coldest = readings.filter_map(&:temp_min_c).min + soil = readings.filter_map(&:soil_temp_c) + { + rain: ("#{number_with_precision(rain.sum, precision: 0, delimiter: ',')} mm" if rain.any?), + hottest: (weather_degrees(hottest) if hottest), + coldest: (weather_degrees(coldest) if coldest), + soil_temp: (weather_degrees(soil.sum / soil.size) if soil.any?), + days: readings.size + }.compact + end + + # How many days the weather charts' X axis spans, from the planting date: the same stretch as the + # progress bar above them, which is the crop's expected lifespan, or longer when the planting is running late. + def weather_axis_days(planting) + [planting.expected_lifespan.to_i, planting.age_in_days.to_i, 1].max + end + + # The seasons along the chart axis for a garden at this latitude: [{ season:, first:, last: }], where + # first and last are the days (0 is `from`) each one covers. Empty when we don't know the hemisphere. + def weather_seasons(latitude, from:, days:) + half = hemisphere(latitude) + return [] unless half + + (0...days).chunk_while { |a, b| season_in(half, from + a) == season_in(half, from + b) }.map do |run| + { season: season_in(half, from + run.first), first: run.first, last: run.last } + end + end + + def weather_degrees(celsius) + "#{celsius.round(1)}°C" + end + + # Daily rainfall as bars, placed by date along an axis that starts on `from` and runs for `days`. + # `expected` is the same for days still to come: paler, because it is a prediction. + def weather_rain_chart(readings, from:, days:, seasons: [], expected: []) + top = [(readings + expected).filter_map(&:rainfall_mm).max.to_f, 1].max + step = WEATHER_CHART_WIDTH.to_f / days + bars = safe_join([weather_rain_bars(readings, from:, step:, top:), + weather_rain_bars(expected, from:, step:, top:, css: 'weather-expected')]) + weather_chart(bars, t('plantings.show.weather_rain_chart_alt', max: top.round, count: readings.size), 'weather-rain', seasons:, days:) + end + + def weather_rain_bars(readings, from:, step:, top:, css: nil) + safe_join(readings.filter_map do |reading| + next unless reading.rainfall_mm&.positive? + + height = (reading.rainfall_mm / top * WEATHER_CHART_HEIGHT).round(1) + tag.rect(class: css, x: ((reading.date - from) * step).round(2), y: WEATHER_CHART_HEIGHT - height, + width: [step, 0.6].max.round(2), height:) + end) + end + + # The daily high and low as two lines, on the same axis. `expected` continues them, dashed and paler, + # from the last observed day. + def weather_temperature_chart(readings, from:, days:, seasons: [], expected: []) + values = (readings + expected).flat_map { |reading| [reading.temp_max_c, reading.temp_min_c] }.compact + return if values.empty? + + low = values.min + scale = { low:, span: [values.max - low, 1].max, step: WEATHER_CHART_WIDTH.to_f / days } + lines = safe_join([weather_temperature_lines(readings, from:, scale:), + weather_temperature_lines(readings.last(1) + expected, from:, css: 'weather-expected', scale:)]) + observed = readings.flat_map { |reading| [reading.temp_max_c, reading.temp_min_c] }.compact + alt = t('plantings.show.weather_temperature_chart_alt', low: weather_degrees(observed.min || values.min), + high: weather_degrees(observed.max || values.max)) + weather_chart(lines, alt, 'weather-temperature', seasons:, days:) + end + + def weather_temperature_lines(readings, from:, scale:, css: nil) + step = scale[:step] + height_of = ->(value) { (value - scale[:low]) / scale[:span] * WEATHER_CHART_HEIGHT } + safe_join({ 'weather-high' => :temp_max_c, 'weather-low' => :temp_min_c }.map do |line, column| + tag.polyline(class: [line, css], points: weather_line_points(readings, column, from:, step:, height_of:)) + end) + end + + # The "x,y" points of one line, placed by date. `height_of` turns a value into its distance from the bottom. + def weather_line_points(readings, column, from:, step:, height_of:) + readings.filter_map do |reading| + value = reading.public_send(column) + next unless value + + "#{((reading.date - from) * step).round(1)},#{(WEATHER_CHART_HEIGHT - height_of.call(value)).round(1)}" + end.join(' ') + end + + # A faint band behind the chart for each season, so the weather can be read against the year. + def weather_season_bands(seasons, days) + step = WEATHER_CHART_WIDTH.to_f / days + safe_join(seasons.map do |band| + tag.rect(class: "weather-season season-#{band[:season]}", x: (band[:first] * step).round(2), y: 0, + width: ((band[:last] - band[:first] + 1) * step).round(2), height: WEATHER_CHART_HEIGHT) + end) + end + + # The seasons' names, laid out under the charts to the same widths as their bands. + def weather_season_strip(seasons, days) + return if seasons.empty? + + tag.div(class: 'weather-seasons', aria: { hidden: true }) do + safe_join(seasons.map do |band| + width = ((band[:last] - band[:first] + 1) * 100.0 / days).round(2) + tag.span(t("plantings.show.weather_season_#{band[:season]}"), class: "season-#{band[:season]}", style: "width: #{width}%") + end) + end + end + + def weather_chart(content, description, css, seasons:, days:) + tag.svg(safe_join([weather_season_bands(seasons, days), content]), class: "weather-chart #{css}", + viewBox: "0 0 #{WEATHER_CHART_WIDTH} #{WEATHER_CHART_HEIGHT}", + preserveAspectRatio: 'none', role: 'img', 'aria-label': description) + end +end diff --git a/app/helpers/plantings_helper.rb b/app/helpers/plantings_helper.rb index da28de660..eb9392d43 100644 --- a/app/helpers/plantings_helper.rb +++ b/app/helpers/plantings_helper.rb @@ -1,111 +1,159 @@ # frozen_string_literal: true module PlantingsHelper - include SeasonsHelper + include PlantingWeatherHelper - WEATHER_CHART_WIDTH = 600 - WEATHER_CHART_HEIGHT = 80 + def display_finished(planting) + if planting.finished_at.present? + planting.finished_at + elsif planting.finished + "Yes (no date specified)" + else + "(no date specified)" + end + end - # The headline figures for a run of weather readings, as text, leaving out any we have no data for. - def weather_summary(readings) - rain = readings.filter_map(&:rainfall_mm) - hottest = readings.filter_map(&:temp_max_c).max - coldest = readings.filter_map(&:temp_min_c).min - soil = readings.filter_map(&:soil_temp_c) + def display_planted_from(planting) + planting.planted_from.presence || "not specified" + end + + def display_planting_quantity(planting) + planting.quantity.presence || "not specified" + end + + def display_planting(planting) + if planting.quantity.to_i > 0 && planting.planted_from.present? + "#{planting.owner} planted #{pluralize(planting.quantity, planting.planted_from)}." + elsif planting.quantity.to_i > 0 + "#{planting.owner} planted #{pluralize(planting.quantity, 'unit')}." + elsif planting.planted_from.present? + "#{planting.owner} planted #{planting.planted_from.pluralize}." + else + "#{planting.owner} planted #{planting.crop}." + end + end + + def days_from_now_to_finished(planting) + return unless planting.finish_is_predicatable? + + (planting.finish_predicted_at - Time.zone.today).to_i + end + + def days_from_now_to_first_harvest(planting) + return unless planting.planted_at.present? && planting.first_harvest_predicted_at.present? + + (planting.first_harvest_predicted_at - Time.zone.today).to_i + end + + # Returns a list of gardens the planting can be transplanted to + # based on the planting's owner. + def transplantable_gardens_by_owner(planting) + @transplantable_gardens ||= {} + cache_key = planting.id || planting.object_id + @transplantable_gardens[cache_key] ||= begin + garden_ids = planting.owner.gardens.select(:id).to_a + GardenCollaborator.where(member_id: planting.owner.id).select(:garden_id).to_a + + Garden.active.where.not(id: planting.garden_id).where(id: garden_ids) + end + end + + def days_from_now_to_last_harvest(planting) + return unless planting.planted_at.present? && planting.last_harvest_predicted_at.present? + + (planting.last_harvest_predicted_at - Time.zone.today).to_i + end + + def planting_classes(planting) + classes = [] + classes << 'planting-growing' if planting.growing? + classes << 'planting-finished' if planting.finished? + classes << 'planting-harvest-time' if planting.harvest_time? + classes << 'planting-late' if planting.late? + classes << 'planting-super-late' if planting.super_late? + classes.join(' ') + end + + def planting_status(planting) + if planting.crop.perennial + t 'planting.status.perennial' + elsif planting.finished? + t 'planting.status.finished' + elsif !planting.finish_is_predicatable? + t 'planting.status.not_enough_data' + elsif planting.harvest_time? + t 'planting.status.harvesting' + elsif planting.late? + t 'planting.status.late' + elsif planting.growing? + t 'planting.status.growing' + elsif !planting.planted? + t 'planting.status.not planted' + else + t 'planting.status.unknown' + end + end + + # What the PlantingActions island needs: the planting itself, the menu items + # (the server still decides labels, links and permissions), and the icons the + # dialogs show in their headers. Mirrors GardenCardSerializer#planting_actions + # so the menu is the same on a card and on the planting's own page. + def planting_actions_props(planting) { - rain: ("#{number_with_precision(rain.sum, precision: 0, delimiter: ',')} mm" if rain.any?), - hottest: (weather_degrees(hottest) if hottest), - coldest: (weather_degrees(coldest) if coldest), - soil_temp: (weather_degrees(soil.sum / soil.size) if soil.any?), - days: readings.size - }.compact + planting: { + id: planting.id, + url: planting_path(planting), + # The finish and harvest dialogs need these: neither may happen before + # the planting was planted, and the server's today is the one that + # counts, not the browser's. + planted_at: planting.planted_at, + today: Time.zone.today, + crop: { name: planting.crop.name, + icon_url: planting.crop.svg_icon.present? ? crop_path(planting.crop, format: 'svg') : nil } + }, + actions: planting_menu_actions(planting), + harvest_icon_url: image_path('icons/harvest.svg'), + seed_icon_url: image_path('icons/seeds.svg'), + photo_icon_url: image_path('icons/photo.svg'), + finish_icon_url: image_path('icons/finish.svg') + } end - # How many days the weather charts' X axis spans, from the planting date: the same stretch as the - # progress bar above them, which is the crop's expected lifespan, or longer when the planting is running late. - def weather_axis_days(planting) - [planting.expected_lifespan.to_i, planting.age_in_days.to_i, 1].max - end + # Edit, add photo, the state changes, then delete last below a divider. + def planting_menu_actions(planting) + return [] unless can?(:edit, planting) - # The seasons along the chart axis for a garden at this latitude: [{ season:, first:, last: }], where - # first and last are the days (0 is `from`) each one covers. Empty when we don't know the hemisphere. - def weather_seasons(latitude, from:, days:) - half = hemisphere(latitude) - return [] unless half - - (0...days).chunk_while { |a, b| season_in(half, from + a) == season_in(half, from + b) }.map do |run| - { season: season_in(half, from + run.first), first: run.first, last: run.last } + actions = [{ key: 'edit', label: t('buttons.edit'), href: edit_planting_path(planting) }] + if can?(:create, Photo) + actions << { key: 'photo', label: t('buttons.add_photo'), + href: new_photo_path(id: planting.id, type: 'planting') } end + actions.concat(active_planting_menu_actions(planting)) if planting.active + actions << planting_delete_action(planting) + actions.compact end - def weather_degrees(celsius) - "#{celsius.round(1)}°C" - end - - # Daily rainfall as bars, placed by date along an axis that starts on `from` and runs for `days`. - def weather_rain_chart(readings, from:, days:, seasons: []) - top = [readings.filter_map(&:rainfall_mm).max.to_f, 1].max - step = WEATHER_CHART_WIDTH.to_f / days - bars = readings.filter_map do |reading| - next unless reading.rainfall_mm&.positive? - - height = (reading.rainfall_mm / top * WEATHER_CHART_HEIGHT).round(1) - tag.rect(x: ((reading.date - from) * step).round(2), y: WEATHER_CHART_HEIGHT - height, - width: [step, 0.6].max.round(2), height:) + def active_planting_menu_actions(planting) + actions = [] + if can?(:create, Harvest) + actions << { key: 'harvest', label: t('buttons.record_harvest'), + href: new_planting_harvest_path(planting_slug: planting.slug) } end - weather_chart(safe_join(bars), t('plantings.show.weather_rain_chart_alt', max: top.round, count: readings.size), 'weather-rain', - seasons:, days:) - end - - # The daily high and low as two lines, on the same axis. - def weather_temperature_chart(readings, from:, days:, seasons: []) - values = readings.flat_map { |reading| [reading.temp_max_c, reading.temp_min_c] }.compact - return if values.empty? - - low = values.min - span = [values.max - low, 1].max - lines = { 'weather-high' => :temp_max_c, 'weather-low' => :temp_min_c }.map do |css, column| - tag.polyline(class: css, points: weather_line_points(readings, column, from:, step: WEATHER_CHART_WIDTH.to_f / days, - height_of: ->(value) { (value - low) / span * WEATHER_CHART_HEIGHT })) + unless planting.failed? + actions << { key: 'seeds', label: t('buttons.save_seeds'), + href: new_planting_seed_path(planting_slug: planting.slug) } end - alt = t('plantings.show.weather_temperature_chart_alt', low: weather_degrees(values.min), high: weather_degrees(values.max)) - weather_chart(safe_join(lines), alt, 'weather-temperature', seasons:, days:) - end - - # The "x,y" points of one line, placed by date. `height_of` turns a value into its distance from the bottom. - def weather_line_points(readings, column, from:, step:, height_of:) - readings.filter_map do |reading| - value = reading.public_send(column) - next unless value - - "#{((reading.date - from) * step).round(1)},#{(WEATHER_CHART_HEIGHT - height_of.call(value)).round(1)}" - end.join(' ') - end - - # A faint band behind the chart for each season, so the weather can be read against the year. - def weather_season_bands(seasons, days) - step = WEATHER_CHART_WIDTH.to_f / days - safe_join(seasons.map do |band| - tag.rect(class: "weather-season season-#{band[:season]}", x: (band[:first] * step).round(2), y: 0, - width: ((band[:last] - band[:first] + 1) * step).round(2), height: WEATHER_CHART_HEIGHT) - end) - end - - # The seasons' names, laid out under the charts to the same widths as their bands. - def weather_season_strip(seasons, days) - return if seasons.empty? - - tag.div(class: 'weather-seasons', aria: { hidden: true }) do - safe_join(seasons.map do |band| - width = ((band[:last] - band[:first] + 1) * 100.0 / days).round(2) - tag.span(t("plantings.show.weather_season_#{band[:season]}"), class: "season-#{band[:season]}", style: "width: #{width}%") - end) + actions << { key: 'finish', label: t('buttons.mark_as_finished'), + href: planting_path(slug: planting.slug, planting: { finished: 1 }), method: 'put' } + if can?(:transplant, planting) && transplantable_gardens_by_owner(planting).any? + actions << { key: 'transplant', label: 'Transplant', href: '#transplant-modal' } end + actions end - def weather_chart(content, description, css, seasons:, days:) - tag.svg(safe_join([weather_season_bands(seasons, days), content]), class: "weather-chart #{css}", - viewBox: "0 0 #{WEATHER_CHART_WIDTH} #{WEATHER_CHART_HEIGHT}", - preserveAspectRatio: 'none', role: 'img', 'aria-label': description) + def planting_delete_action(planting) + return unless can?(:destroy, planting) + + { key: 'delete', label: t('buttons.delete'), href: planting_path(planting), + method: 'delete', confirm: t('are_you_sure'), divider: true } end end diff --git a/app/models/planting.rb b/app/models/planting.rb index ba5c061d1..03a535f50 100644 --- a/app/models/planting.rb +++ b/app/models/planting.rb @@ -193,6 +193,19 @@ class Planting < ApplicationRecord .between(planted_at, [finished_at, Time.zone.today].compact.min).order(:date) end + # For a planting still growing: the weather the rest of its life will probably bring, as the readings + # for the same days a year earlier, moved forward a year. A prediction, not an observation. + def expected_weather_readings + return [] if finished? || failed? || planted_at.blank? || expected_lifespan.blank? || latitude.blank? || longitude.blank? + + from = (weather_readings.maximum(:date) || (planted_at - 1)) + 1 + to = planted_at + expected_lifespan.to_i + return [] if from > to + + WeatherReading.at(*WeatherReading.grid_point(latitude, longitude)).between(from.prev_year, to.prev_year).order(:date) + .map { |reading| reading.dup.tap { |expected| expected.date = reading.date.next_year } } + end + def nearby_same_crop return @nearby_same_crop if defined?(@nearby_same_crop) diff --git a/app/services/weather_updater.rb b/app/services/weather_updater.rb index 9b643f0ab..a76502893 100644 --- a/app/services/weather_updater.rb +++ b/app/services/weather_updater.rb @@ -50,11 +50,17 @@ class WeatherUpdater def build_cell(latitude, longitude, plantings) latest_available = @today - LAG_DAYS ranges = plantings.map do |planting| - [planting.planted_at, [planting.finished_at, planting.planted_at + MAX_PLANTING_DAYS, latest_available].compact.min] + [history_start(planting), [planting.finished_at, planting.planted_at + MAX_PLANTING_DAYS, latest_available].compact.min] end Cell.new(latitude, longitude, [ranges.map(&:first).min, FIRST_DAY].max, ranges.map(&:last).max, plantings.size) end + # A planting still growing is shown the rest of its life as last year's weather for the same days, + # so we also want the year before it started. + def history_start(planting) + planting.finished? ? planting.planted_at : planting.planted_at.prev_year + end + def fetch_current(cell) latest = readings(cell).maximum(:date) fetch(cell, latest ? latest + 1 : [cell.from, cell.to - CURRENT_DAYS + 1].max, cell.to) diff --git a/app/views/plantings/_weather.html.haml b/app/views/plantings/_weather.html.haml index 3ac641ccd..c4e1dd02d 100644 --- a/app/views/plantings/_weather.html.haml +++ b/app/views/plantings/_weather.html.haml @@ -5,11 +5,13 @@ .weather-charts %figure %figcaption= t('plantings.show.weather_temperature_chart') - = weather_temperature_chart(readings, from: planting.planted_at, days:, seasons:) + = weather_temperature_chart(readings, from: planting.planted_at, days:, seasons:, expected:) %figure %figcaption= t('plantings.show.weather_rain_chart') - = weather_rain_chart(readings, from: planting.planted_at, days:, seasons:) + = weather_rain_chart(readings, from: planting.planted_at, days:, seasons:, expected:) = weather_season_strip(seasons, days) +- if expected.any? + %p.weather-expected-note.small.text-muted= t('plantings.show.weather_expected_note') %dl.weather-summary - weather_summary(readings).each do |key, value| .weather-figure diff --git a/app/views/plantings/show.html.haml b/app/views/plantings/show.html.haml index 1965f5f6d..755d87779 100644 --- a/app/views/plantings/show.html.haml +++ b/app/views/plantings/show.html.haml @@ -66,7 +66,7 @@ = render 'timeline', planting: @planting - if @weather.any? - #weather.planting-weather= render 'plantings/weather', planting: @planting, readings: @weather + #weather.planting-weather= render 'plantings/weather', planting: @planting, readings: @weather, expected: @expected_weather - elsif @planting.planted? && @planting.latitude.blank? && can?(:edit, @planting.garden) -# Without a location there is no weather to show; only someone who can set one is asked to. %p.planting-weather-prompt.small diff --git a/config/locales/en.yml b/config/locales/en.yml index f032cde12..020eb5d65 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -972,6 +972,7 @@ en: weather_coldest: Coldest night weather_credit_html: Weather for the area around the garden, modelled and not measured in it, a few days behind. Data from %{link} (CC BY 4.0). weather_days: Days + weather_expected_note: The paler lines and bars are expected weather, from the same days last year. They are not observed. weather_hottest: Hottest day weather_rain: Rain weather_rain_chart: Daily rainfall diff --git a/spec/helpers/plantings_helper_spec.rb b/spec/helpers/plantings_helper_spec.rb index 94847710d..ba38b75cb 100644 --- a/spec/helpers/plantings_helper_spec.rb +++ b/spec/helpers/plantings_helper_spec.rb @@ -128,4 +128,22 @@ describe PlantingsHelper do expect(helper.weather_seasons(nil, from: Date.new(2026, 8, 25), days: 3)).to be_empty end end + + describe 'expected weather' do + let(:from) { Date.new(2026, 10, 1) } + let(:observed) { [build(:weather_reading, date: from, rainfall_mm: 2.0)] } + let(:expected) { [build(:weather_reading, date: from + 1, rainfall_mm: 4.0)] } + + it 'draws expected rain as paler bars' do + expect(helper.weather_rain_chart(observed, from:, days: 10, expected:).scan('class="weather-expected"').size).to eq 1 + end + + it 'draws expected temperature as paler lines, high and low' do + expect(helper.weather_temperature_chart(observed, from:, days: 10, expected:).scan('weather-expected').size).to eq 2 + end + + it 'draws no expected lines when there is nothing expected' do + expect(helper.weather_temperature_chart(observed, from:, days: 10)).not_to include 'weather-expected' + end + end end diff --git a/spec/models/planting_spec.rb b/spec/models/planting_spec.rb index 2ac739459..6c943964a 100644 --- a/spec/models/planting_spec.rb +++ b/spec/models/planting_spec.rb @@ -867,4 +867,34 @@ describe Planting do it { is_expected.to be_empty } end end + + describe '#expected_weather_readings' do + subject(:expected) { planting.expected_weather_readings } + + let(:today) { Time.zone.today } + let(:garden) { create(:garden).tap { |g| g.update_columns(latitude: -41.29, longitude: 174.78) } } # rubocop:disable Rails/SkipsModelValidations + let(:crop) { create(:crop, median_lifespan: 10) } + let(:planting) { create(:planting, crop:, garden:, owner: garden.owner, planted_at: today - 4) } + + before do + create(:weather_reading, date: today - 3) + create(:weather_reading, date: (today + 2).prev_year, rainfall_mm: 7.0) + end + + it 'moves last year\'s readings forward a year' do + expect(expected.map(&:date)).to eq [today + 2] + end + + it 'carries last year\'s values' do + expect(expected.first.rainfall_mm).to eq 7.0 + end + + context 'when the planting is finished' do + let(:planting) do + create(:planting, crop:, garden:, owner: garden.owner, planted_at: today - 4, finished: true, finished_at: today - 1) + end + + it { is_expected.to be_empty } + end + end end diff --git a/spec/services/weather_updater_spec.rb b/spec/services/weather_updater_spec.rb index cf9850ad9..5865663b0 100644 --- a/spec/services/weather_updater_spec.rb +++ b/spec/services/weather_updater_spec.rb @@ -22,8 +22,8 @@ describe WeatherUpdater, type: :service do context 'with an active planting in a garden with a location' do before { create(:planting, garden:, owner: garden.owner, planted_at: today - 10) } - it 'saves a reading for each day up to the lag' do - expect { updater.update_all }.to change(WeatherReading, :count).by(6) + it 'saves a reading for each day from a year before planting up to the lag' do + expect { updater.update_all }.to change(WeatherReading, :count).by(371) end it 'records where the reading came from' do @@ -37,7 +37,7 @@ describe WeatherUpdater, type: :service do end it 'only asks for days it lacks on a second run' do - expect(client).to(receive(:daily).once { |from:, to:, **| (from..to).index_with { day } }) + expect(client).to(receive(:daily).twice { |from:, to:, **| (from..to).index_with { day } }) 2.times { updater.update_all } end end @@ -49,8 +49,8 @@ describe WeatherUpdater, type: :service do create(:planting, garden: other, owner: other.owner, planted_at: today - 10) end - it 'makes one request' do - expect(client).to(receive(:daily).once { |from:, to:, **| (from..to).index_with { day } }) + it 'asks once for current weather and once for history' do + expect(client).to(receive(:daily).twice { |from:, to:, **| (from..to).index_with { day } }) updater.update_all end end @@ -59,7 +59,7 @@ describe WeatherUpdater, type: :service do before { create(:planting, garden:, owner: garden.owner, planted_at: today - 100) } it 'fills in history when the budget allows' do - expect { updater.update_all }.to change(WeatherReading, :count).by(96) + expect { updater.update_all }.to change(WeatherReading, :count).by(461) end context 'with a budget for current weather only' do @@ -79,8 +79,16 @@ describe WeatherUpdater, type: :service do context 'with a planting from years ago still marked active' do before { create(:planting, garden:, owner: garden.owner, planted_at: today - 4000) } - it 'saves only its first year' do - expect { updater.update_all }.to change(WeatherReading, :count).by(367) + it 'saves its first year, and the year before' do + expect { updater.update_all }.to change(WeatherReading, :count).by(732) + end + end + + context 'with a planting still growing' do + before { create(:planting, garden:, owner: garden.owner, planted_at: today - 10) } + + it 'also fetches the year before it started' do + expect { updater.update_all }.to change { WeatherReading.exists?(date: today - 10 - 200) }.to(true) end end