From 49824319a1d1b252d3aaee90382c56e5ad5bc407 Mon Sep 17 00:00:00 2001 From: litefarm-pr-bot <266148868+litefarm-pr-bot@users.noreply.github.com> Date: Thu, 21 May 2026 12:38:27 -0700 Subject: [PATCH 01/28] LF-5298: Replace Home weather widget with 5-day forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the legacy WeatherBoard for a new WeatherForecast widget driven by OpenWeather's /data/2.5/forecast endpoint. The widget shows a 5-day / 3-hour forecast with five day pills, a horizontal time strip of 3-hour slots, a shared rollover selection (one slot index drives a derived day pill highlight), and a red frost-risk banner whenever the day's minimum forecast temperature is below 2°C. Backend always requests metric units; the frontend converts on display using the farm's measurement setting. All local-day grouping, day pill labels and time chip labels use the farm timezone from city.timezone in the OpenWeather response rather than the browser timezone. Co-Authored-By: Claude Opus 4.7 --- .../api/src/controllers/weatherController.ts | 5 +- packages/api/src/endPoints.js | 2 +- packages/api/src/services/weatherService.ts | 65 ++++--- .../webapp/public/locales/en/translation.json | 10 +- .../assets/weatherBoard.module.scss | 103 ----------- .../src/components/WeatherBoard/index.jsx | 31 ---- .../WeatherForecast/DayPillRow/index.tsx | 62 +++++++ .../DayPillRow/styles.module.scss | 100 +++++++++++ .../DayWeatherSummary/index.tsx | 82 +++++++++ .../DayWeatherSummary/styles.module.scss | 65 +++++++ .../WeatherForecast/FrostBanner/index.tsx | 32 ++++ .../FrostBanner/styles.module.scss | 11 ++ .../WeatherForecast/TimeStrip/index.tsx | 98 ++++++++++ .../TimeStrip/styles.module.scss | 84 +++++++++ .../src/components/WeatherForecast/index.tsx | 85 +++++++++ .../WeatherForecast/styles.module.scss | 20 +++ packages/webapp/src/containers/Home/index.jsx | 4 +- .../src/containers/WeatherBoard/index.jsx | 33 ---- .../src/containers/WeatherForecast/index.tsx | 87 +++++++++ .../containers/WeatherForecast/selectors.ts | 167 ++++++++++++++++++ packages/webapp/src/store/api/types/index.ts | 20 ++- packages/webapp/src/store/api/weatherApi.ts | 10 +- .../stories/Pages/Home/PureHome.stories.jsx | 8 +- .../WeatherBoard/PureWeather.stories.jsx | 33 ---- .../stories/WeatherBoard/Weather.stories.jsx | 25 --- .../WeatherForecast/DayPillRow.stories.tsx | 67 +++++++ .../DayWeatherSummary.stories.tsx | 59 +++++++ .../WeatherForecast/FrostBanner.stories.tsx | 30 ++++ .../PureWeatherForecast.stories.tsx | 99 +++++++++++ .../WeatherForecast/TimeStrip.stories.tsx | 52 ++++++ .../src/stories/WeatherForecast/mockData.ts | 63 +++++++ 31 files changed, 1343 insertions(+), 269 deletions(-) delete mode 100644 packages/webapp/src/components/WeatherBoard/assets/weatherBoard.module.scss delete mode 100644 packages/webapp/src/components/WeatherBoard/index.jsx create mode 100644 packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx create mode 100644 packages/webapp/src/components/WeatherForecast/DayPillRow/styles.module.scss create mode 100644 packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx create mode 100644 packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss create mode 100644 packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx create mode 100644 packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss create mode 100644 packages/webapp/src/components/WeatherForecast/TimeStrip/index.tsx create mode 100644 packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss create mode 100644 packages/webapp/src/components/WeatherForecast/index.tsx create mode 100644 packages/webapp/src/components/WeatherForecast/styles.module.scss delete mode 100644 packages/webapp/src/containers/WeatherBoard/index.jsx create mode 100644 packages/webapp/src/containers/WeatherForecast/index.tsx create mode 100644 packages/webapp/src/containers/WeatherForecast/selectors.ts delete mode 100644 packages/webapp/src/stories/WeatherBoard/PureWeather.stories.jsx delete mode 100644 packages/webapp/src/stories/WeatherBoard/Weather.stories.jsx create mode 100644 packages/webapp/src/stories/WeatherForecast/DayPillRow.stories.tsx create mode 100644 packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx create mode 100644 packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx create mode 100644 packages/webapp/src/stories/WeatherForecast/PureWeatherForecast.stories.tsx create mode 100644 packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx create mode 100644 packages/webapp/src/stories/WeatherForecast/mockData.ts diff --git a/packages/api/src/controllers/weatherController.ts b/packages/api/src/controllers/weatherController.ts index b563d4d793..dce74af77a 100644 --- a/packages/api/src/controllers/weatherController.ts +++ b/packages/api/src/controllers/weatherController.ts @@ -29,13 +29,12 @@ const weatherController = { return res.sendStatus(404); } - const weatherData = await weatherService.getWeather({ + const weatherForecast = await weatherService.getWeather({ lat: row.grid_points.lat, lon: row.grid_points.lng, - units: row.units.measurement, }); - res.status(200).send(weatherData); + res.status(200).send(weatherForecast); } catch (error: unknown) { console.error(error); res.status((error as HttpError).status || 500).json({ error }); diff --git a/packages/api/src/endPoints.js b/packages/api/src/endPoints.js index 227d08cd87..c16d6f8e93 100644 --- a/packages/api/src/endPoints.js +++ b/packages/api/src/endPoints.js @@ -17,7 +17,7 @@ const endPoints = { googleMapsAPI: 'https://maps.googleapis.com/maps/api/elevation/json', // for grabbing elevation googleMapsAPIGeocode: 'https://maps.googleapis.com/maps/api/geocode/json', // for grabbing elevation googleWebRiskAPI: 'https://webrisk.googleapis.com/v1/uris:search', - openWeatherAPI: 'https://api.openweathermap.org/data/2.5/weather', // for grabbing weather data + openWeatherAPI: 'https://api.openweathermap.org/data/2.5/forecast', // for grabbing 5-day / 3-hour forecast openMapsAPI: 'https://nominatim.openstreetmap.org/reverse', // to reverse geocode soilGridsAPI: 'https://rest.isric.org/soilgrids/v2.0/properties/query', // for grabbing soil organic matter when no soil analysis is present gbifAPI: 'http://api.gbif.org/v1/occurrence/search', // for grabbing species in biodiversity diff --git a/packages/api/src/services/weatherService.ts b/packages/api/src/services/weatherService.ts index e81ea73993..5f20894afc 100644 --- a/packages/api/src/services/weatherService.ts +++ b/packages/api/src/services/weatherService.ts @@ -20,38 +20,63 @@ import endpoints from '../endPoints.js'; interface WeatherParams { lat: number; lon: number; - units?: string; } -interface WeatherData { +export interface WeatherForecastSlot { + dt: number; + tempC: number; + tempMinC: number; + iconCode: string; + pop: number; + rainMm3h: number; + windMs: number; humidity: number; - icon: string; - date: number; - temp: number; - wind: number; - city: string; - measurement: string; +} + +export interface WeatherForecast { + city: { name: string; timezoneOffsetSeconds: number }; + slots: WeatherForecastSlot[]; +} + +interface OpenWeatherListEntry { + dt: number; + main: { temp: number; temp_min: number; humidity: number }; + weather: { icon: string }[]; + wind: { speed: number }; + rain?: { '3h'?: number }; + pop?: number; +} + +interface OpenWeatherForecastResponse { + list: OpenWeatherListEntry[]; + city: { name: string; timezone: number }; } const OPEN_WEATHER_APP_ID = credentials.OPEN_WEATHER_APP_ID; const openWeatherAPI = endpoints.openWeatherAPI; export const weatherService = { - async getWeather({ lat, lon, units = 'metric' }: WeatherParams): Promise { + async getWeather({ lat, lon }: WeatherParams): Promise { try { - const url = `${openWeatherAPI}?units=${units}&lat=${lat}&lon=${lon}&appid=${OPEN_WEATHER_APP_ID}&lang=en&cnt=1`; - const response = await axios.get(url); - - const data = await response.data; + const url = `${openWeatherAPI}?units=metric&lat=${lat}&lon=${lon}&appid=${OPEN_WEATHER_APP_ID}&lang=en`; + const response = await axios.get(url); + const data = response.data; return { - humidity: data.main.humidity, - icon: data.weather[0].icon, - date: data.dt, - temp: Math.round(data.main.temp), - wind: data.wind.speed, - city: data.name, - measurement: units, + city: { + name: data.city.name, + timezoneOffsetSeconds: data.city.timezone, + }, + slots: data.list.map((entry) => ({ + dt: entry.dt, + tempC: entry.main.temp, + tempMinC: entry.main.temp_min, + iconCode: entry.weather[0].icon, + pop: entry.pop ?? 0, + rainMm3h: entry.rain?.['3h'] ?? 0, + windMs: entry.wind.speed, + humidity: entry.main.humidity, + })), }; } catch (error) { const axiosError = error as AxiosError; diff --git a/packages/webapp/public/locales/en/translation.json b/packages/webapp/public/locales/en/translation.json index 6d5c85a135..62f31f686b 100644 --- a/packages/webapp/public/locales/en/translation.json +++ b/packages/webapp/public/locales/en/translation.json @@ -2245,8 +2245,16 @@ "HOURLY_WAGE_TOOLTIP": "This task-specific wage will override the assignee's default wage. Default worker wages can be managed in the People tab." }, "WEATHER": { + "FROST_EXPECTED": "⚠️ Frost Expected (Low {{threshold}})", + "FROST_RISK": "Frost risk", "HUMIDITY": "Humidity", - "WIND": "Wind" + "NEXT_TIME_SLOT": "Next time slot", + "PRECIPITATION": "Precipitation", + "PREVIOUS_TIME_SLOT": "Previous time slot", + "TITLE": "Weather & conditions", + "TODAY": "Today", + "WIND": "Wind", + "WIND_SPEED": "Wind speed" }, "WELCOME_SCREEN": { "BUTTON": "Let's get started" diff --git a/packages/webapp/src/components/WeatherBoard/assets/weatherBoard.module.scss b/packages/webapp/src/components/WeatherBoard/assets/weatherBoard.module.scss deleted file mode 100644 index 3c23aec241..0000000000 --- a/packages/webapp/src/components/WeatherBoard/assets/weatherBoard.module.scss +++ /dev/null @@ -1,103 +0,0 @@ -@use '../../../assets/mixin' as *; - -.container { - top: 0; - transform: translateY(calc(39.68vh - 110px)); - margin: 0 8.89% 0 8.89%; - padding: 4.44% 9.44%; - width: 84%; - display: grid; - grid-template-areas: - 'city city' - 'date date' - 'temperature icon' - 'wind humidity'; - word-break: break-word; - background-color: var(--overlay); - font-size: 3.88vw; - line-height: 6.66vw; - color: #ffffff; - text-align: center; -} - -.city { - grid-area: city; - font-size: 5vw; - font-weight: bold; -} - -.date { - grid-area: date; -} - -.temperature { - grid-area: temperature; - font-size: 14.44vw; - line-height: 13.33vw; - display: flex; - align-items: center; - justify-content: center; - padding-top: 16px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.icon { - grid-area: icon; - display: flex; - align-items: center; - font-size: 15.55vw; - justify-content: center; - padding-top: 16px; -} - -.wind { - grid-area: wind; - padding-top: 16px; -} - -.humidity { - grid-area: humidity; - padding-top: 16px; -} - -@include sm-breakpoint { - .container { - font-size: 2.8vw; - } -} - -@media only screen and (min-width: 1024px) { - .container { - transform: translateY(calc((100vh - 399px) * 0.65 - 110px)); - margin: 0 auto 0 auto; - padding: 42.609px 90.516px; - width: 806px; - font-size: 36.75px; - line-height: 63px; - } - - .city { - font-size: 47.25px; - } - - .temperature { - font-size: 136.5px; - line-height: 126px; - padding-top: 20px; - } - - .icon { - font-size: 144.375px; - padding-top: 20px; - } - - .wind { - padding-top: 20px; - } - - .humidity { - padding-top: 20px; - } -} diff --git a/packages/webapp/src/components/WeatherBoard/index.jsx b/packages/webapp/src/components/WeatherBoard/index.jsx deleted file mode 100644 index b1b1279f75..0000000000 --- a/packages/webapp/src/components/WeatherBoard/index.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import styles from './assets/weatherBoard.module.scss'; -import PropTypes from 'prop-types'; -import WeatherIcon from './WeatherIcon'; - -/** - * TooShort UI component for user interaction - */ -export default function PureWeatherBoard({ city, date, temperature, iconName, wind, humidity }) { - return ( -
-
{city}
-
{date}
-
{temperature}
-
- -
-
{wind}
-
{humidity}
-
- ); -} - -PureWeatherBoard.propTypes = { - city: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - temperature: PropTypes.string.isRequired, - iconName: PropTypes.string.isRequired, - wind: PropTypes.string.isRequired, - humidity: PropTypes.string.isRequired, -}; diff --git a/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx b/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx new file mode 100644 index 0000000000..612b9a8ce6 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import clsx from 'clsx'; +import type { ForecastDay } from '../../../containers/WeatherForecast/selectors'; +import styles from './styles.module.scss'; + +export interface DayPillRowProps { + days: ForecastDay[]; + selectedDayIndex: number; + labels: string[]; + frostLabel: string; + onDayClick: (dayIndex: number) => void; +} + +const DayPillRow = ({ + days, + selectedDayIndex, + labels, + frostLabel, + onDayClick, +}: DayPillRowProps) => { + return ( +
+ {days.map((day, index) => { + const selected = index === selectedDayIndex; + return ( + + ); + })} +
+ ); +}; + +export default DayPillRow; diff --git a/packages/webapp/src/components/WeatherForecast/DayPillRow/styles.module.scss b/packages/webapp/src/components/WeatherForecast/DayPillRow/styles.module.scss new file mode 100644 index 0000000000..5214c00c1c --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/DayPillRow/styles.module.scss @@ -0,0 +1,100 @@ +.row { + display: flex; + gap: 8px; + width: 100%; +} + +.pill { + flex: 1 1 0; + min-width: 0; + height: 72px; + padding: 16px; + background-color: var(--White); + border: 1px solid var(--Brand-Accents-colors-and-overlays-Accent-green-light); + border-radius: 8px; + color: #1d444b; + font-size: 14px; + line-height: 1.2; + font-family: inherit; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + position: relative; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + + &:hover { + background-color: #f2fcfa; + } + + &:focus-visible { + outline: 2px solid var(--Colors-Primary-Primary-teal-400); + outline-offset: 2px; + } +} + +.selected { + background-color: #f2fcfa; + border: 1px solid #a1c5be; + + &::after { + content: ''; + position: absolute; + left: 50%; + bottom: -8px; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #a1c5be; + } + + &::before { + content: ''; + position: absolute; + left: 50%; + bottom: -7px; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 7px solid #f2fcfa; + z-index: 1; + } +} + +.label { + font-weight: 500; +} + +.divider { + display: block; + width: 100%; + height: 1px; + background-color: var(--Brand-Accents-colors-and-overlays-Accent-green-light); +} + +.frostLabel { + font-size: 12px; + font-weight: 700; + color: var(--Brand-Accents-colors-and-overlays-Accent-red); + line-height: 1.2; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx new file mode 100644 index 0000000000..4b124c9cef --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { useTranslation } from 'react-i18next'; +import type { ForecastDay, Measurement } from '../../../containers/WeatherForecast/selectors'; +import { + convertPrecipitationForDisplay, + convertTempForDisplay, + convertWindForDisplay, + formatLongDate, + frostThresholdLabel, +} from '../../../containers/WeatherForecast/selectors'; +import type { WeatherForecastSlot } from '../../../store/api/types'; +import DescriptionList, { LabelSize } from '../../Tile/DescriptionList'; +import WeatherIcon from '../../WeatherBoard/WeatherIcon'; +import weatherBoardUtil from '../../../containers/WeatherBoard/utils'; +import FrostBanner from '../FrostBanner'; +import styles from './styles.module.scss'; + +interface DayWeatherSummaryProps { + day: ForecastDay; + selectedSlot: WeatherForecastSlot; + measurement: Measurement; + locale: string; +} + +const DayWeatherSummary = ({ day, selectedSlot, measurement, locale }: DayWeatherSummaryProps) => { + const { t } = useTranslation(); + const temp = convertTempForDisplay(selectedSlot.tempC, measurement); + const wind = convertWindForDisplay(selectedSlot.windMs, measurement); + const precip = convertPrecipitationForDisplay(selectedSlot.rainMm3h, measurement); + + return ( +
+ {day.isFrost && } +
+ {formatLongDate(day, locale)} +
+ + {temp.value} + {temp.unit} + + +
+
+ +
+ ); +}; + +export default DayWeatherSummary; diff --git a/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss new file mode 100644 index 0000000000..46134bd7e8 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss @@ -0,0 +1,65 @@ +.summary { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.titleRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + width: 100%; +} + +.date { + font-size: 16px; + font-weight: 700; + color: var(--Colors-Primary-Primary-teal-700); + line-height: 1.3; +} + +.tempBlock { + display: flex; + align-items: center; + gap: 8px; +} + +.temp { + font-size: 40px; + font-weight: 700; + color: var(--Colors-Primary-Primary-teal-700); + line-height: 1; +} + +.tempBlock :global(.wi) { + font-size: 56px; + color: var(--Colors-Primary-Primary-teal-700); +} + +.metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + margin: 0; + padding: 0; +} + +.metrics > * { + border-radius: 0; +} + +.metrics > *:first-child { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} + +.metrics > *:last-child { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +.metrics > * + * { + border-left: 0; +} diff --git a/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx b/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx new file mode 100644 index 0000000000..bc6823cb80 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { useTranslation } from 'react-i18next'; +import styles from './styles.module.scss'; + +interface FrostBannerProps { + thresholdLabel: string; +} + +const FrostBanner = ({ thresholdLabel }: FrostBannerProps) => { + const { t } = useTranslation(); + return ( +
+ {t('WEATHER.FROST_EXPECTED', { threshold: thresholdLabel })} +
+ ); +}; + +export default FrostBanner; diff --git a/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss b/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss new file mode 100644 index 0000000000..b7719e8a01 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss @@ -0,0 +1,11 @@ +.banner { + width: 100%; + padding: 4px 8px; + background-color: #fff0f0; + color: var(--Brand-Accents-colors-and-overlays-Accent-red); + border-radius: 8px; + text-align: center; + font-size: 14px; + font-weight: 600; + line-height: 1.4; +} diff --git a/packages/webapp/src/components/WeatherForecast/TimeStrip/index.tsx b/packages/webapp/src/components/WeatherForecast/TimeStrip/index.tsx new file mode 100644 index 0000000000..e1785a3c1c --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/TimeStrip/index.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { useEffect, useRef } from 'react'; +import ChevronLeft from '@mui/icons-material/ChevronLeft'; +import ChevronRight from '@mui/icons-material/ChevronRight'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import { formatTimeChipLabel } from '../../../containers/WeatherForecast/selectors'; +import type { WeatherForecastSlot } from '../../../store/api/types'; +import styles from './styles.module.scss'; + +interface TimeStripProps { + slots: WeatherForecastSlot[]; + selectedSlotIndex: number; + offsetSeconds: number; + locale: string; + onSelect: (slotIndex: number) => void; + onPrev: () => void; + onNext: () => void; +} + +const TimeStrip = ({ + slots, + selectedSlotIndex, + offsetSeconds, + locale, + onSelect, + onPrev, + onNext, +}: TimeStripProps) => { + const { t } = useTranslation(); + const selectedRef = useRef(null); + + useEffect(() => { + selectedRef.current?.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }); + }, [selectedSlotIndex]); + + return ( +
+ +
+
+ {slots.map((slot, index) => { + const selected = index === selectedSlotIndex; + return ( + + ); + })} +
+
+ +
+ ); +}; + +export default TimeStrip; diff --git a/packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss b/packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss new file mode 100644 index 0000000000..4c73c3aa37 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss @@ -0,0 +1,84 @@ +.strip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 8px; + background-color: var(--Colors-Primary-Primary-teal-50); + border-radius: 8px; +} + +.chevron { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: 0; + border-radius: 4px; + color: var(--Colors-Primary-Primary-teal-700); + cursor: pointer; + + svg { + width: 24px; + height: 24px; + } + + &:disabled { + color: var(--Colors-Neutral-Neutral-200); + cursor: default; + } + + &:focus-visible { + outline: 2px solid var(--Colors-Primary-Primary-teal-400); + outline-offset: 2px; + } +} + +.slotsScroll { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.slotsRow { + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; +} + +.chip { + flex: 0 0 auto; + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + padding: 8px 16px; + font-size: 14px; + font-weight: 400; + line-height: 1; + color: var(--Colors-Primary-Primary-teal-900); + font-family: inherit; + cursor: pointer; + white-space: nowrap; + + &:focus-visible { + outline: 2px solid var(--Colors-Primary-Primary-teal-400); + outline-offset: 2px; + } +} + +.selected { + background-color: var(--Colors-Primary-Primary-teal-700); + color: var(--Colors-Primary-Primary-teal-50); + border-color: var(--Colors-Primary-Primary-teal-400); +} diff --git a/packages/webapp/src/components/WeatherForecast/index.tsx b/packages/webapp/src/components/WeatherForecast/index.tsx new file mode 100644 index 0000000000..627e1c333e --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/index.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { useTranslation } from 'react-i18next'; +import type { ForecastDay, Measurement } from '../../containers/WeatherForecast/selectors'; +import type { WeatherForecastSlot } from '../../store/api/types'; +import DayPillRow from './DayPillRow'; +import DayWeatherSummary from './DayWeatherSummary'; +import TimeStrip from './TimeStrip'; +import styles from './styles.module.scss'; + +export interface PureWeatherForecastProps { + days: ForecastDay[]; + dayPillLabels: string[]; + selectedDayIndex: number; + selectedSlot: WeatherForecastSlot; + selectedSlotIndex: number; + slots: WeatherForecastSlot[]; + offsetSeconds: number; + measurement: Measurement; + locale: string; + onDayClick: (dayIndex: number) => void; + onSelectSlot: (slotIndex: number) => void; + onPrev: () => void; + onNext: () => void; +} + +const PureWeatherForecast = ({ + days, + dayPillLabels, + selectedDayIndex, + selectedSlot, + selectedSlotIndex, + slots, + offsetSeconds, + measurement, + locale, + onDayClick, + onSelectSlot, + onPrev, + onNext, +}: PureWeatherForecastProps) => { + const { t } = useTranslation(); + return ( +
+

{t('WEATHER.TITLE')}

+ + + +
+ ); +}; + +export default PureWeatherForecast; diff --git a/packages/webapp/src/components/WeatherForecast/styles.module.scss b/packages/webapp/src/components/WeatherForecast/styles.module.scss new file mode 100644 index 0000000000..c7bacf3a56 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/styles.module.scss @@ -0,0 +1,20 @@ +.card { + display: flex; + flex-direction: column; + gap: 32px; + width: 100%; + padding: 32px; + background-color: var(--White); + border-radius: 4px; + box-shadow: 0 4px 2px rgba(0, 0, 0, 0.25); +} + +.title { + margin: 0; + font-family: 'Open Sans', sans-serif; + font-size: 16px; + font-weight: 400; + text-transform: uppercase; + color: var(--Colors-Neutral-Neutral-900); + letter-spacing: 0.02em; +} diff --git a/packages/webapp/src/containers/Home/index.jsx b/packages/webapp/src/containers/Home/index.jsx index df9fbad8c9..2893429e0d 100644 --- a/packages/webapp/src/containers/Home/index.jsx +++ b/packages/webapp/src/containers/Home/index.jsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { getSeason } from './utils/season'; -import WeatherBoard from '../../containers/WeatherBoard'; +import WeatherForecast from '../../containers/WeatherForecast'; import PureHome from '../../components/Home'; import { userFarmSelector } from '../userFarmSlice'; import { useTranslation } from 'react-i18next'; @@ -47,7 +47,7 @@ export default function Home() { > - {userFarm ? : null} + {userFarm ? : null} {showSwitchFarmModal && !showSpotLight && } {showExportModal && dismissExportModal(false)} />} diff --git a/packages/webapp/src/containers/WeatherBoard/index.jsx b/packages/webapp/src/containers/WeatherBoard/index.jsx deleted file mode 100644 index 80bc91f18a..0000000000 --- a/packages/webapp/src/containers/WeatherBoard/index.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import PureWeatherBoard from '../../components/WeatherBoard'; -import { useSelector } from 'react-redux'; -import utils from './utils'; -import { useTranslation } from 'react-i18next'; -import { getLanguageFromLocalStorage } from '../../util/getLanguageFromLocalStorage'; -import { userFarmSelector } from '../userFarmSlice'; -import { useGetWeatherQuery } from '../../store/api/weatherApi'; - -export default function WeatherBoard() { - const { - units: { measurement }, - } = useSelector(userFarmSelector); - - const { data } = useGetWeatherQuery({ - measurementSystem: measurement, - }); - const language_preference = getLanguageFromLocalStorage(); - - const { t } = useTranslation(); - const { tempUnit, speedUnit } = utils.getUnits(data?.measurement); - const formattedForecast = { - humidity: `${t('WEATHER.HUMIDITY')}: ${data?.humidity}%`, - iconName: utils.getIcon(data?.icon), - date: utils.formatDate(language_preference, data?.date ? data?.date * 1000 : new Date()), - temperature: `${data?.temp}${tempUnit}`, - wind: `${t('WEATHER.WIND')}: ${data?.wind} ${speedUnit}`, - city: data?.city, - }; - - return data ? : ; -} - -WeatherBoard.propTypes = {}; diff --git a/packages/webapp/src/containers/WeatherForecast/index.tsx b/packages/webapp/src/containers/WeatherForecast/index.tsx new file mode 100644 index 0000000000..11170cf2a5 --- /dev/null +++ b/packages/webapp/src/containers/WeatherForecast/index.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import PureWeatherForecast from '../../components/WeatherForecast'; +import { useGetWeatherForecastQuery } from '../../store/api/weatherApi'; +import { measurementSelector } from '../userFarmSlice'; +import { + formatDayPillLabel, + groupSlotsByLocalDay, + localHourOfSlot, + localYmdFromUtcMs, + type Measurement, +} from './selectors'; + +export default function WeatherForecast() { + const { t, i18n } = useTranslation(); + const measurement = useSelector(measurementSelector) as Measurement; + const { data, isLoading } = useGetWeatherForecastQuery(); + const [selectedSlotIndex, setSelectedSlotIndex] = useState(0); + + const offsetSeconds = data?.city.timezoneOffsetSeconds ?? 0; + + const days = useMemo(() => (data ? groupSlotsByLocalDay(data) : []), [data]); + + const todayLocalYmd = useMemo( + () => localYmdFromUtcMs(Date.now(), offsetSeconds), + [offsetSeconds], + ); + + const dayPillLabels = useMemo( + () => days.map((d) => formatDayPillLabel(d, todayLocalYmd, i18n.language, t('WEATHER.TODAY'))), + [days, todayLocalYmd, i18n.language, t], + ); + + const selectedDayIndex = useMemo( + () => days.findIndex((d) => d.slotIndices.includes(selectedSlotIndex)), + [days, selectedSlotIndex], + ); + + if (isLoading || !data || data.slots.length === 0) { + return null; + } + + const selectedSlot = data.slots[selectedSlotIndex]; + + const onDayClick = (dayIndex: number) => { + const currentHour = localHourOfSlot(selectedSlot, offsetSeconds); + const target = days[dayIndex]; + const match = target.slotIndices.find( + (i) => localHourOfSlot(data.slots[i], offsetSeconds) === currentHour, + ); + setSelectedSlotIndex(match ?? target.slotIndices[0]); + }; + + return ( + setSelectedSlotIndex((i) => Math.max(0, i - 1))} + onNext={() => setSelectedSlotIndex((i) => Math.min(data.slots.length - 1, i + 1))} + /> + ); +} diff --git a/packages/webapp/src/containers/WeatherForecast/selectors.ts b/packages/webapp/src/containers/WeatherForecast/selectors.ts new file mode 100644 index 0000000000..af53d6d80d --- /dev/null +++ b/packages/webapp/src/containers/WeatherForecast/selectors.ts @@ -0,0 +1,167 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import type { WeatherForecast, WeatherForecastSlot } from '../../store/api/types'; + +export type Measurement = 'metric' | 'imperial'; + +export type ForecastDay = { + localYmd: string; + slotIndices: number[]; + isFrost: boolean; + dayLowC: number; + summaryIconCode: string; +}; + +export function localYmdFromUtcMs(utcMs: number, offsetSeconds: number): string { + return new Date(utcMs + offsetSeconds * 1000).toISOString().slice(0, 10); +} + +export function localHourOfSlot(slot: WeatherForecastSlot, offsetSeconds: number): number { + const localMs = (slot.dt + offsetSeconds) * 1000; + const d = new Date(localMs); + return d.getUTCHours() + d.getUTCMinutes() / 60; +} + +function pickSummaryIconCode( + slots: WeatherForecastSlot[], + slotIndices: number[], + offsetSeconds: number, +): string { + let bestIndex = slotIndices[0]; + let bestDist = Infinity; + for (const i of slotIndices) { + const dist = Math.abs(localHourOfSlot(slots[i], offsetSeconds) - 12); + if (dist < bestDist) { + bestDist = dist; + bestIndex = i; + } + } + return slots[bestIndex].iconCode; +} + +export function groupSlotsByLocalDay(forecast: WeatherForecast): ForecastDay[] { + const { slots } = forecast; + const offsetSeconds = forecast.city.timezoneOffsetSeconds; + + const byYmd = new Map(); + slots.forEach((slot, index) => { + const ymd = localYmdFromUtcMs(slot.dt * 1000, offsetSeconds); + const bucket = byYmd.get(ymd); + if (bucket) { + bucket.push(index); + } else { + byYmd.set(ymd, [index]); + } + }); + + const days: ForecastDay[] = []; + for (const [localYmd, slotIndices] of byYmd) { + const dayLowC = Math.min(...slotIndices.map((i) => slots[i].tempMinC)); + days.push({ + localYmd, + slotIndices, + isFrost: dayLowC < 2, + dayLowC, + summaryIconCode: pickSummaryIconCode(slots, slotIndices, offsetSeconds), + }); + } + return days; +} + +function parseLocalYmdAsUtcNoon(localYmd: string): Date { + const [y, m, d] = localYmd.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d, 12)); +} + +export function formatDayPillLabel( + day: ForecastDay, + todayLocalYmd: string, + locale: string, + todayLabel: string, +): string { + if (day.localYmd === todayLocalYmd) { + return todayLabel; + } + const date = parseLocalYmdAsUtcNoon(day.localYmd); + return date.toLocaleDateString(locale, { weekday: 'short', timeZone: 'UTC' }); +} + +export function formatLongDate(day: ForecastDay, locale: string): string { + const date = parseLocalYmdAsUtcNoon(day.localYmd); + const weekday = date.toLocaleDateString(locale, { weekday: 'short', timeZone: 'UTC' }); + const monthDay = date.toLocaleDateString(locale, { + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); + return `${weekday}, ${monthDay}`; +} + +export function formatTimeChipLabel( + slot: WeatherForecastSlot, + offsetSeconds: number, + locale: string, +): string { + const localMs = (slot.dt + offsetSeconds) * 1000; + const date = new Date(localMs); + const formatter = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: '2-digit', + timeZone: 'UTC', + }); + let label = formatter.format(date); + if (date.getUTCMinutes() === 0) { + label = label.replace(/:00/, ''); + } + return label + .replace(/\s*AM$/i, 'am') + .replace(/\s*PM$/i, 'pm') + .trim(); +} + +export function convertTempForDisplay( + tempC: number, + measurement: Measurement, +): { value: number; unit: '°C' | '°F' } { + if (measurement === 'imperial') { + return { value: Math.round((tempC * 9) / 5 + 32), unit: '°F' }; + } + return { value: Math.round(tempC), unit: '°C' }; +} + +export function convertWindForDisplay( + windMs: number, + measurement: Measurement, +): { value: number; unit: 'km/h' | 'mph' } { + if (measurement === 'imperial') { + return { value: Math.round(windMs * 2.237), unit: 'mph' }; + } + return { value: Math.round(windMs * 3.6), unit: 'km/h' }; +} + +export function convertPrecipitationForDisplay( + rainMm: number, + measurement: Measurement, +): { value: number; unit: 'mm' | 'in' } { + if (measurement === 'imperial') { + return { value: Number((rainMm * 0.0394).toFixed(2)), unit: 'in' }; + } + return { value: Math.round(rainMm), unit: 'mm' }; +} + +export function frostThresholdLabel(measurement: Measurement): string { + return measurement === 'imperial' ? '<36°F' : '<2°C'; +} diff --git a/packages/webapp/src/store/api/types/index.ts b/packages/webapp/src/store/api/types/index.ts index 09f7c61833..6f786a1db3 100644 --- a/packages/webapp/src/store/api/types/index.ts +++ b/packages/webapp/src/store/api/types/index.ts @@ -299,14 +299,20 @@ export interface SensorReadings { readings: SensorDatapoint[]; } -export interface WeatherData { +export interface WeatherForecastSlot { + dt: number; + tempC: number; + tempMinC: number; + iconCode: string; + pop: number; + rainMm3h: number; + windMs: number; humidity: number; - icon: string; - date: number; - temp: number; - wind: number; - city: string; - measurement: string; +} + +export interface WeatherForecast { + city: { name: string; timezoneOffsetSeconds: number }; + slots: WeatherForecastSlot[]; } export interface IrrigationPrescription { id: number; diff --git a/packages/webapp/src/store/api/weatherApi.ts b/packages/webapp/src/store/api/weatherApi.ts index 250ae30f6d..9a413528e9 100644 --- a/packages/webapp/src/store/api/weatherApi.ts +++ b/packages/webapp/src/store/api/weatherApi.ts @@ -15,16 +15,16 @@ import { api } from './apiSlice'; import { weatherUrl } from '../../apiConfig'; -import { WeatherData } from './types'; +import { WeatherForecast } from './types'; export const weatherApi = api.injectEndpoints({ endpoints: (build) => ({ - getWeather: build.query({ - query: ({ measurementSystem }) => `${weatherUrl}`, + getWeatherForecast: build.query({ + query: () => weatherUrl, providesTags: ['Weather'], - keepUnusedDataFor: 7200, // Cache data for 2 hours (7200 seconds) + keepUnusedDataFor: 7200, }), }), }); -export const { useGetWeatherQuery } = weatherApi; +export const { useGetWeatherForecastQuery } = weatherApi; diff --git a/packages/webapp/src/stories/Pages/Home/PureHome.stories.jsx b/packages/webapp/src/stories/Pages/Home/PureHome.stories.jsx index 2c14cd7c56..07dfd1df36 100644 --- a/packages/webapp/src/stories/Pages/Home/PureHome.stories.jsx +++ b/packages/webapp/src/stories/Pages/Home/PureHome.stories.jsx @@ -1,6 +1,5 @@ import React from 'react'; import { authenticatedDecorators } from '../config/Decorators'; -import { Rain } from '../../WeatherBoard/PureWeather.stories'; import PureHome from '../../../components/Home'; import { chromaticSmallScreen } from '../config/chromatic'; @@ -12,14 +11,13 @@ export default { const Template = (args) => ; -export const HomeRain = Template.bind({}); -HomeRain.args = { +export const Default = Template.bind({}); +Default.args = { greeting: 'Good morning,', first_name: ' User Name', - children: , imgUrl: 'https://res.cloudinary.com/dfxanglyc/image/upload/v1552774058/portfolio/1024px-Nail___Gear.svg.png', }; -HomeRain.parameters = { +Default.parameters = { ...chromaticSmallScreen, }; diff --git a/packages/webapp/src/stories/WeatherBoard/PureWeather.stories.jsx b/packages/webapp/src/stories/WeatherBoard/PureWeather.stories.jsx deleted file mode 100644 index 16cf55d1f4..0000000000 --- a/packages/webapp/src/stories/WeatherBoard/PureWeather.stories.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import PureWeatherBoard from '../../components/WeatherBoard'; -import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; - -export default { - title: 'Components/WeatherBoard/PureWeatherBoard', - component: PureWeatherBoard, - decorators: componentDecoratorsWithoutPadding, -}; - -const Template = (args) => ; - -export const Rain = Template.bind({}); - -Rain.args = { - city: 'Vancouver', - date: 'Wed 16 September', - temperature: '15ºC', - iconName: 'wi-day-rain', - wind: 'Wind: 2 km/h', - humidity: 'Humidity: 31%', -}; - -export const Sunny = Template.bind({}); - -Sunny.args = { - city: 'Vancouver', - date: 'Wed 16 September', - temperature: '15ºC', - iconName: 'wi-day-sunny', - wind: 'Wind: 2 km/h', - humidity: 'Humidity: 31%', -}; diff --git a/packages/webapp/src/stories/WeatherBoard/Weather.stories.jsx b/packages/webapp/src/stories/WeatherBoard/Weather.stories.jsx deleted file mode 100644 index 88734fec72..0000000000 --- a/packages/webapp/src/stories/WeatherBoard/Weather.stories.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import WeatherBoard from '../../containers/WeatherBoard'; -import decorators from '../Pages/config/Decorators'; - -export default { - title: 'Components/WeatherBoard/WeatherBoardWrapper', - component: WeatherBoard, - decorators, -}; - -const Template = (args) => ; - -export const English = Template.bind({}); - -English.args = {}; - -export const Espanol = Template.bind({}); -Espanol.args = {}; - -English.parameters = { - chromatic: { disable: true }, -}; -Espanol.parameters = { - chromatic: { disable: true }, -}; diff --git a/packages/webapp/src/stories/WeatherForecast/DayPillRow.stories.tsx b/packages/webapp/src/stories/WeatherForecast/DayPillRow.stories.tsx new file mode 100644 index 0000000000..929d21a4b6 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/DayPillRow.stories.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import DayPillRow from '../../components/WeatherForecast/DayPillRow'; +import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; +import { + formatDayPillLabel, + groupSlotsByLocalDay, + localYmdFromUtcMs, +} from '../../containers/WeatherForecast/selectors'; +import { buildMockForecast } from './mockData'; + +const forecast = buildMockForecast({ frostDayIndex: 2 }); +const days = groupSlotsByLocalDay(forecast).slice(0, 5); +const todayYmd = localYmdFromUtcMs( + forecast.slots[0].dt * 1000, + forecast.city.timezoneOffsetSeconds, +); +const labels = days.map((d) => formatDayPillLabel(d, todayYmd, 'en-US', 'Today')); + +const meta: Meta = { + title: 'Components/WeatherForecast/DayPillRow', + component: DayPillRow, + decorators: componentDecoratorsWithoutPadding, +}; +export default meta; + +type Story = StoryObj; + +const Wrapper = (args: { initialIndex: number }) => { + const [selectedDayIndex, setSelectedDayIndex] = useState(args.initialIndex); + return ( + + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const FutureDaySelected: Story = { + render: () => , +}; + +export const FrostDaySelected: Story = { + render: () => , +}; diff --git a/packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx b/packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx new file mode 100644 index 0000000000..9d649bdef0 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import DayWeatherSummary from '../../components/WeatherForecast/DayWeatherSummary'; +import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; +import { groupSlotsByLocalDay } from '../../containers/WeatherForecast/selectors'; +import { buildMockForecast } from './mockData'; + +const forecast = buildMockForecast({ frostDayIndex: 2 }); +const days = groupSlotsByLocalDay(forecast); + +const meta: Meta = { + title: 'Components/WeatherForecast/DayWeatherSummary', + component: DayWeatherSummary, + decorators: componentDecoratorsWithoutPadding, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + day: days[0], + selectedSlot: forecast.slots[days[0].slotIndices[0]], + measurement: 'metric', + locale: 'en-US', + }, +}; + +export const WithFrostBanner: Story = { + args: { + day: days[2], + selectedSlot: forecast.slots[days[2].slotIndices[0]], + measurement: 'metric', + locale: 'en-US', + }, +}; + +export const Imperial: Story = { + args: { + day: days[0], + selectedSlot: forecast.slots[days[0].slotIndices[0]], + measurement: 'imperial', + locale: 'en-US', + }, +}; diff --git a/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx new file mode 100644 index 0000000000..da1eefcaf5 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import FrostBanner from '../../components/WeatherForecast/FrostBanner'; +import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; + +const meta: Meta = { + title: 'Components/WeatherForecast/FrostBanner', + component: FrostBanner, + decorators: componentDecoratorsWithoutPadding, +}; +export default meta; + +type Story = StoryObj; + +export const Metric: Story = { args: { thresholdLabel: '<2°C' } }; +export const Imperial: Story = { args: { thresholdLabel: '<36°F' } }; diff --git a/packages/webapp/src/stories/WeatherForecast/PureWeatherForecast.stories.tsx b/packages/webapp/src/stories/WeatherForecast/PureWeatherForecast.stories.tsx new file mode 100644 index 0000000000..4b4cfbe2fd --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/PureWeatherForecast.stories.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { useMemo, useState } from 'react'; +import PureWeatherForecast from '../../components/WeatherForecast'; +import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; +import { + formatDayPillLabel, + groupSlotsByLocalDay, + localHourOfSlot, + localYmdFromUtcMs, + type Measurement, +} from '../../containers/WeatherForecast/selectors'; +import { buildMockForecast } from './mockData'; + +const meta: Meta = { + title: 'Components/WeatherForecast/PureWeatherForecast', + component: PureWeatherForecast, + decorators: componentDecoratorsWithoutPadding, +}; +export default meta; + +type Story = StoryObj; + +const Wrapper = ({ + frostDayIndex, + initialSlotIndex, + measurement = 'metric', +}: { + frostDayIndex?: number; + initialSlotIndex: number; + measurement?: Measurement; +}) => { + const forecast = useMemo(() => buildMockForecast({ frostDayIndex }), [frostDayIndex]); + const days = useMemo(() => groupSlotsByLocalDay(forecast), [forecast]); + const [selectedSlotIndex, setSelectedSlotIndex] = useState(initialSlotIndex); + const todayYmd = localYmdFromUtcMs( + forecast.slots[0].dt * 1000, + forecast.city.timezoneOffsetSeconds, + ); + const labels = days.map((d) => formatDayPillLabel(d, todayYmd, 'en-US', 'Today')); + const selectedDayIndex = days.findIndex((d) => d.slotIndices.includes(selectedSlotIndex)); + const selectedSlot = forecast.slots[selectedSlotIndex]; + + const onDayClick = (dayIndex: number) => { + const currentHour = localHourOfSlot(selectedSlot, forecast.city.timezoneOffsetSeconds); + const target = days[dayIndex]; + const match = target.slotIndices.find( + (i) => + localHourOfSlot(forecast.slots[i], forecast.city.timezoneOffsetSeconds) === currentHour, + ); + setSelectedSlotIndex(match ?? target.slotIndices[0]); + }; + + return ( + setSelectedSlotIndex((i) => Math.max(0, i - 1))} + onNext={() => setSelectedSlotIndex((i) => Math.min(forecast.slots.length - 1, i + 1))} + /> + ); +}; + +export const Default: Story = { render: () => }; + +export const FutureDaySelected: Story = { + render: () => , +}; + +export const FrostDaySelected: Story = { + render: () => , +}; + +export const Imperial: Story = { + render: () => , +}; diff --git a/packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx b/packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx new file mode 100644 index 0000000000..cdf8b2c9ef --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import TimeStrip from '../../components/WeatherForecast/TimeStrip'; +import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; +import { buildMockForecast } from './mockData'; + +const forecast = buildMockForecast(); + +const meta: Meta = { + title: 'Components/WeatherForecast/TimeStrip', + component: TimeStrip, + decorators: componentDecoratorsWithoutPadding, +}; +export default meta; + +type Story = StoryObj; + +const Wrapper = (args: { initialIndex: number }) => { + const [selectedSlotIndex, setSelectedSlotIndex] = useState(args.initialIndex); + return ( + setSelectedSlotIndex((i) => Math.max(0, i - 1))} + onNext={() => setSelectedSlotIndex((i) => Math.min(forecast.slots.length - 1, i + 1))} + /> + ); +}; + +export const FirstSlotSelected: Story = { render: () => }; +export const MiddleSlotSelected: Story = { render: () => }; +export const LastSlotSelected: Story = { + render: () => , +}; diff --git a/packages/webapp/src/stories/WeatherForecast/mockData.ts b/packages/webapp/src/stories/WeatherForecast/mockData.ts new file mode 100644 index 0000000000..ee5f1a2be6 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/mockData.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import type { WeatherForecast, WeatherForecastSlot } from '../../store/api/types'; + +const OFFSET_SECONDS_VANCOUVER = -7 * 3600; + +function buildSlot( + dtUtcSeconds: number, + overrides: Partial = {}, +): WeatherForecastSlot { + return { + dt: dtUtcSeconds, + tempC: 12, + tempMinC: 10, + iconCode: '02d', + pop: 0.1, + rainMm3h: 0, + windMs: 3, + humidity: 55, + ...overrides, + }; +} + +export function buildMockForecast({ + frostDayIndex, +}: { frostDayIndex?: number } = {}): WeatherForecast { + const baseSeconds = Math.floor(new Date(Date.UTC(2026, 2, 9, 0, 0, 0)).getTime() / 1000); + const slots: WeatherForecastSlot[] = []; + for (let day = 0; day < 5; day += 1) { + for (let step = 0; step < 8; step += 1) { + const dt = baseSeconds + (day * 8 + step) * 3 * 3600; + const isFrostDay = frostDayIndex === day; + const baseTemp = isFrostDay ? -2 + step * 0.5 : 8 + step * 0.7; + slots.push( + buildSlot(dt, { + tempC: baseTemp, + tempMinC: baseTemp - 1.5, + iconCode: step >= 3 && step <= 5 ? '10d' : '02d', + rainMm3h: step === 4 ? 1.4 : 0, + windMs: 2 + (step % 3), + humidity: 50 + (step % 4) * 5, + }), + ); + } + } + return { + city: { name: 'Vancouver', timezoneOffsetSeconds: OFFSET_SECONDS_VANCOUVER }, + slots, + }; +} From 73fa20b69e841371e999daec45e77e4cf56b5b53 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Fri, 22 May 2026 09:40:18 -0700 Subject: [PATCH 02/28] LF-5298 Restore comment in weatherApi --- packages/webapp/src/store/api/weatherApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webapp/src/store/api/weatherApi.ts b/packages/webapp/src/store/api/weatherApi.ts index 9a413528e9..f951b70031 100644 --- a/packages/webapp/src/store/api/weatherApi.ts +++ b/packages/webapp/src/store/api/weatherApi.ts @@ -20,9 +20,9 @@ import { WeatherForecast } from './types'; export const weatherApi = api.injectEndpoints({ endpoints: (build) => ({ getWeatherForecast: build.query({ - query: () => weatherUrl, + query: () => `${weatherUrl}`, providesTags: ['Weather'], - keepUnusedDataFor: 7200, + keepUnusedDataFor: 7200, // Cache data for 2 hours (7200 seconds) }), }), }); From 3d0a8db66c23efd6a0862276ca8d2084ec00b5a9 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Fri, 22 May 2026 15:23:57 -0700 Subject: [PATCH 03/28] LF-5298 Improve formatLongDate function --- packages/webapp/src/containers/WeatherForecast/selectors.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/webapp/src/containers/WeatherForecast/selectors.ts b/packages/webapp/src/containers/WeatherForecast/selectors.ts index af53d6d80d..14e0c63faa 100644 --- a/packages/webapp/src/containers/WeatherForecast/selectors.ts +++ b/packages/webapp/src/containers/WeatherForecast/selectors.ts @@ -101,13 +101,12 @@ export function formatDayPillLabel( export function formatLongDate(day: ForecastDay, locale: string): string { const date = parseLocalYmdAsUtcNoon(day.localYmd); - const weekday = date.toLocaleDateString(locale, { weekday: 'short', timeZone: 'UTC' }); - const monthDay = date.toLocaleDateString(locale, { + return date.toLocaleDateString(locale, { + weekday: 'short', month: 'long', day: 'numeric', timeZone: 'UTC', }); - return `${weekday}, ${monthDay}`; } export function formatTimeChipLabel( From 5c80652751156b9ad85e98d22224c1e59c852bcd Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 25 May 2026 11:00:06 -0700 Subject: [PATCH 04/28] LF-5298 Update TimeStrip CSS --- .../TimeStrip/styles.module.scss | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss b/packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss index 4c73c3aa37..d007d087b6 100644 --- a/packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss +++ b/packages/webapp/src/components/WeatherForecast/TimeStrip/styles.module.scss @@ -1,3 +1,18 @@ +/* + * Copyright 2026 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + .strip { display: flex; align-items: center; @@ -19,7 +34,7 @@ background: transparent; border: 0; border-radius: 4px; - color: var(--Colors-Primary-Primary-teal-700); + color: var(--Colors-Primary-Primary-teal-900); cursor: pointer; svg { @@ -65,7 +80,7 @@ padding: 8px 16px; font-size: 14px; font-weight: 400; - line-height: 1; + line-height: normal; color: var(--Colors-Primary-Primary-teal-900); font-family: inherit; cursor: pointer; @@ -81,4 +96,6 @@ background-color: var(--Colors-Primary-Primary-teal-700); color: var(--Colors-Primary-Primary-teal-50); border-color: var(--Colors-Primary-Primary-teal-400); + font-weight: 700; + box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.05); } From eeb4221ea7ede2606eb114178ef3c9d9e443cf58 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 25 May 2026 11:00:48 -0700 Subject: [PATCH 05/28] LF-5298 Update TimeStrip stories decorator --- .../src/stories/WeatherForecast/TimeStrip.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx b/packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx index cdf8b2c9ef..6cb48984e2 100644 --- a/packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx +++ b/packages/webapp/src/stories/WeatherForecast/TimeStrip.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiteFarm.org + * Copyright 2026 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -16,7 +16,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; import TimeStrip from '../../components/WeatherForecast/TimeStrip'; -import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; +import { componentDecorators } from '../Pages/config/Decorators'; import { buildMockForecast } from './mockData'; const forecast = buildMockForecast(); @@ -24,7 +24,7 @@ const forecast = buildMockForecast(); const meta: Meta = { title: 'Components/WeatherForecast/TimeStrip', component: TimeStrip, - decorators: componentDecoratorsWithoutPadding, + decorators: componentDecorators, }; export default meta; From 571f80e1c2a1bede0154c9e1911e5a40dc80d92e Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 25 May 2026 11:37:18 -0700 Subject: [PATCH 06/28] LF-5298 Adjust FrostBanner component and stories --- .../WeatherForecast/FrostBanner/index.tsx | 7 +++++-- .../FrostBanner/styles.module.scss | 17 ++++++++++++++++- .../WeatherForecast/FrostBanner.stories.tsx | 10 +++++----- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx b/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx index bc6823cb80..4f9b24eb55 100644 --- a/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx +++ b/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiteFarm.org + * Copyright 2026 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -24,7 +24,10 @@ const FrostBanner = ({ thresholdLabel }: FrostBannerProps) => { const { t } = useTranslation(); return (
- {t('WEATHER.FROST_EXPECTED', { threshold: thresholdLabel })} + {t('WEATHER.FROST_EXPECTED', { + threshold: thresholdLabel, + interpolation: { escapeValue: false }, + })}
); }; diff --git a/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss b/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss index b7719e8a01..60bf022bef 100644 --- a/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss +++ b/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss @@ -1,3 +1,18 @@ +/* + * Copyright 2026 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + .banner { width: 100%; padding: 4px 8px; @@ -7,5 +22,5 @@ text-align: center; font-size: 14px; font-weight: 600; - line-height: 1.4; + line-height: normal; } diff --git a/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx index da1eefcaf5..f20f0bb23e 100644 --- a/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx +++ b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiteFarm.org + * Copyright 2026 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -15,16 +15,16 @@ import type { Meta, StoryObj } from '@storybook/react'; import FrostBanner from '../../components/WeatherForecast/FrostBanner'; -import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; +import { componentDecorators } from '../Pages/config/Decorators'; const meta: Meta = { title: 'Components/WeatherForecast/FrostBanner', component: FrostBanner, - decorators: componentDecoratorsWithoutPadding, + decorators: componentDecorators, }; export default meta; type Story = StoryObj; -export const Metric: Story = { args: { thresholdLabel: '<2°C' } }; -export const Imperial: Story = { args: { thresholdLabel: '<36°F' } }; +export const Metric: Story = { args: { thresholdLabel: '< 2°C' } }; +export const Imperial: Story = { args: { thresholdLabel: '< 36°F' } }; From 8f53bab61f0d08e8ea5b2cfa7454103fd5eff8e5 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 25 May 2026 13:46:22 -0700 Subject: [PATCH 07/28] LF-5298 Adjust DayWeatherSummary component --- .../DayWeatherSummary/index.tsx | 16 +++---- .../DayWeatherSummary/styles.module.scss | 44 ++++++++++--------- .../WeatherForecast/FrostBanner/index.tsx | 7 +-- .../containers/WeatherForecast/selectors.ts | 40 ++++++----------- .../DayWeatherSummary.stories.tsx | 12 ++--- .../WeatherForecast/FrostBanner.stories.tsx | 4 +- .../src/stories/WeatherForecast/mockData.ts | 2 +- 7 files changed, 57 insertions(+), 68 deletions(-) diff --git a/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx index 4b124c9cef..f38ab10fef 100644 --- a/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx +++ b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/index.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiteFarm.org + * Copyright 2026 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -20,7 +20,6 @@ import { convertTempForDisplay, convertWindForDisplay, formatLongDate, - frostThresholdLabel, } from '../../../containers/WeatherForecast/selectors'; import type { WeatherForecastSlot } from '../../../store/api/types'; import DescriptionList, { LabelSize } from '../../Tile/DescriptionList'; @@ -40,18 +39,15 @@ const DayWeatherSummary = ({ day, selectedSlot, measurement, locale }: DayWeathe const { t } = useTranslation(); const temp = convertTempForDisplay(selectedSlot.tempC, measurement); const wind = convertWindForDisplay(selectedSlot.windMs, measurement); - const precip = convertPrecipitationForDisplay(selectedSlot.rainMm3h, measurement); + const precipitation = convertPrecipitationForDisplay(selectedSlot.rainMm3h, measurement); return (
- {day.isFrost && } + {day.isFrost && }
{formatLongDate(day, locale)}
- - {temp.value} - {temp.unit} - + {temp}
@@ -60,7 +56,7 @@ const DayWeatherSummary = ({ day, selectedSlot, measurement, locale }: DayWeathe descriptionListTilesProps={[ { label: t('WEATHER.PRECIPITATION'), - data: `${precip.value} ${precip.unit}`, + data: precipitation, labelSize: LabelSize.SMALL, }, { @@ -70,7 +66,7 @@ const DayWeatherSummary = ({ day, selectedSlot, measurement, locale }: DayWeathe }, { label: t('WEATHER.WIND_SPEED'), - data: `${wind.value} ${wind.unit}`, + data: wind, labelSize: LabelSize.SMALL, }, ]} diff --git a/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss index 46134bd7e8..7a1c42ebba 100644 --- a/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss +++ b/packages/webapp/src/components/WeatherForecast/DayWeatherSummary/styles.module.scss @@ -1,3 +1,18 @@ +/* + * Copyright 2026 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + .summary { display: flex; flex-direction: column; @@ -17,13 +32,12 @@ font-size: 16px; font-weight: 700; color: var(--Colors-Primary-Primary-teal-700); - line-height: 1.3; } .tempBlock { display: flex; align-items: center; - gap: 8px; + gap: 20px; } .temp { @@ -34,32 +48,22 @@ } .tempBlock :global(.wi) { - font-size: 56px; + font-size: 50px; color: var(--Colors-Primary-Primary-teal-700); } .metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0; - margin: 0; - padding: 0; + color: var(--Colors-Primary-Primary-teal-900); + letter-spacing: 0.4px; + text-align: left; + + dd { + font-size: 16px; + } } .metrics > * { border-radius: 0; } - -.metrics > *:first-child { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; -} - -.metrics > *:last-child { - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; -} - -.metrics > * + * { - border-left: 0; -} diff --git a/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx b/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx index 4f9b24eb55..8f08b34b22 100644 --- a/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx +++ b/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx @@ -14,18 +14,19 @@ */ import { useTranslation } from 'react-i18next'; +import { frostThresholdLabel, Measurement } from '../../../containers/WeatherForecast/selectors'; import styles from './styles.module.scss'; interface FrostBannerProps { - thresholdLabel: string; + measurement: Measurement; } -const FrostBanner = ({ thresholdLabel }: FrostBannerProps) => { +const FrostBanner = ({ measurement }: FrostBannerProps) => { const { t } = useTranslation(); return (
{t('WEATHER.FROST_EXPECTED', { - threshold: thresholdLabel, + threshold: frostThresholdLabel(measurement), interpolation: { escapeValue: false }, })}
diff --git a/packages/webapp/src/containers/WeatherForecast/selectors.ts b/packages/webapp/src/containers/WeatherForecast/selectors.ts index 14e0c63faa..1d74a33d41 100644 --- a/packages/webapp/src/containers/WeatherForecast/selectors.ts +++ b/packages/webapp/src/containers/WeatherForecast/selectors.ts @@ -13,6 +13,7 @@ * GNU General Public License for more details, see . */ +import { convert } from '../../util/convert-units/convert'; import type { WeatherForecast, WeatherForecastSlot } from '../../store/api/types'; export type Measurement = 'metric' | 'imperial'; @@ -130,37 +131,24 @@ export function formatTimeChipLabel( .replace(/\s*PM$/i, 'pm') .trim(); } - -export function convertTempForDisplay( - tempC: number, - measurement: Measurement, -): { value: number; unit: '°C' | '°F' } { - if (measurement === 'imperial') { - return { value: Math.round((tempC * 9) / 5 + 32), unit: '°F' }; - } - return { value: Math.round(tempC), unit: '°C' }; +export function convertTempForDisplay(tempC: number, measurement: Measurement): string { + const value = Math.round(measurement === 'metric' ? tempC : convert(tempC).from('C').to('F')); + const unit = measurement === 'metric' ? '°C' : '°F'; + return `${value}${unit}`; } -export function convertWindForDisplay( - windMs: number, - measurement: Measurement, -): { value: number; unit: 'km/h' | 'mph' } { - if (measurement === 'imperial') { - return { value: Math.round(windMs * 2.237), unit: 'mph' }; - } - return { value: Math.round(windMs * 3.6), unit: 'km/h' }; +export function convertWindForDisplay(windMs: number, measurement: Measurement): string { + const unit = measurement === 'metric' ? 'km/h' : 'mph'; + const value = Math.round(convert(windMs).from('m/s').to(unit)); + return `${value} ${unit}`; } -export function convertPrecipitationForDisplay( - rainMm: number, - measurement: Measurement, -): { value: number; unit: 'mm' | 'in' } { - if (measurement === 'imperial') { - return { value: Number((rainMm * 0.0394).toFixed(2)), unit: 'in' }; - } - return { value: Math.round(rainMm), unit: 'mm' }; +export function convertPrecipitationForDisplay(rainMm: number, measurement: Measurement): string { + const unit = measurement === 'imperial' ? 'in' : 'mm'; + const value = Math.round(convert(rainMm).from('mm').to(unit)); + return `${value} ${unit}`; } export function frostThresholdLabel(measurement: Measurement): string { - return measurement === 'imperial' ? '<36°F' : '<2°C'; + return measurement === 'imperial' ? '< 36°F' : '< 2°C'; } diff --git a/packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx b/packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx index 9d649bdef0..5cc0c83665 100644 --- a/packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx +++ b/packages/webapp/src/stories/WeatherForecast/DayWeatherSummary.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiteFarm.org + * Copyright 2026 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -15,7 +15,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import DayWeatherSummary from '../../components/WeatherForecast/DayWeatherSummary'; -import { componentDecoratorsWithoutPadding } from '../Pages/config/Decorators'; +import { componentDecorators } from '../Pages/config/Decorators'; import { groupSlotsByLocalDay } from '../../containers/WeatherForecast/selectors'; import { buildMockForecast } from './mockData'; @@ -25,7 +25,7 @@ const days = groupSlotsByLocalDay(forecast); const meta: Meta = { title: 'Components/WeatherForecast/DayWeatherSummary', component: DayWeatherSummary, - decorators: componentDecoratorsWithoutPadding, + decorators: componentDecorators, }; export default meta; @@ -36,7 +36,7 @@ export const Default: Story = { day: days[0], selectedSlot: forecast.slots[days[0].slotIndices[0]], measurement: 'metric', - locale: 'en-US', + locale: 'en', }, }; @@ -45,7 +45,7 @@ export const WithFrostBanner: Story = { day: days[2], selectedSlot: forecast.slots[days[2].slotIndices[0]], measurement: 'metric', - locale: 'en-US', + locale: 'en', }, }; @@ -54,6 +54,6 @@ export const Imperial: Story = { day: days[0], selectedSlot: forecast.slots[days[0].slotIndices[0]], measurement: 'imperial', - locale: 'en-US', + locale: 'en', }, }; diff --git a/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx index f20f0bb23e..2c4acbc4d8 100644 --- a/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx +++ b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx @@ -26,5 +26,5 @@ export default meta; type Story = StoryObj; -export const Metric: Story = { args: { thresholdLabel: '< 2°C' } }; -export const Imperial: Story = { args: { thresholdLabel: '< 36°F' } }; +export const Metric: Story = { args: { measurement: 'metric' } }; +export const Imperial: Story = { args: { measurement: 'imperial' } }; diff --git a/packages/webapp/src/stories/WeatherForecast/mockData.ts b/packages/webapp/src/stories/WeatherForecast/mockData.ts index ee5f1a2be6..1f9713a32f 100644 --- a/packages/webapp/src/stories/WeatherForecast/mockData.ts +++ b/packages/webapp/src/stories/WeatherForecast/mockData.ts @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiteFarm.org + * Copyright 2026 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify From e095b44a9761b5c7e4628e17f336b1ae2da0ece8 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 25 May 2026 14:31:24 -0700 Subject: [PATCH 08/28] LF-5298 Adjust DayPillRow --- .../WeatherForecast/DayPillRow/index.tsx | 24 ++---- .../DayPillRow/styles.module.scss | 73 ++++++++++--------- .../src/components/WeatherForecast/index.tsx | 3 +- .../WeatherForecast/DayPillRow.stories.tsx | 9 +-- 4 files changed, 49 insertions(+), 60 deletions(-) diff --git a/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx b/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx index 612b9a8ce6..d3fc50c9de 100644 --- a/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx +++ b/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiteFarm.org + * Copyright 2026 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -14,6 +14,7 @@ */ import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; import type { ForecastDay } from '../../../containers/WeatherForecast/selectors'; import styles from './styles.module.scss'; @@ -21,17 +22,12 @@ export interface DayPillRowProps { days: ForecastDay[]; selectedDayIndex: number; labels: string[]; - frostLabel: string; onDayClick: (dayIndex: number) => void; } -const DayPillRow = ({ - days, - selectedDayIndex, - labels, - frostLabel, - onDayClick, -}: DayPillRowProps) => { +const DayPillRow = ({ days, selectedDayIndex, labels, onDayClick }: DayPillRowProps) => { + const { t } = useTranslation(); + return (
{days.map((day, index) => { @@ -44,14 +40,8 @@ const DayPillRow = ({ className={clsx(styles.pill, selected && styles.selected)} onClick={() => onDayClick(index)} > - {labels[index]} - {day.isFrost && ( - <> -