mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-05 04:32:48 -05:00
charts of temp and rain
This commit is contained in:
1 parent
c3c64bc0ef
commit
b671b4ebff
12 files changed
+381
-104
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user