charts of temp and rain

This commit is contained in:
Brenda Wallace committed 2026-10-31 17:24:30 +13:00
1 parent c3c64bc0ef
commit b671b4ebff
12 files changed
+381 -104

No files matched your search

+24
View File
@@ -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;
+1
View File
@@ -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
+126
View File
@@ -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
+140 -92
View File
@@ -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
+13
View File
@@ -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)
+7 -1
View File
@@ -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)
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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
+18
View File
@@ -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
+30
View File
@@ -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
+16 -8
View File
@@ -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