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..5e8e32ffa4 100644 --- a/packages/api/src/services/weatherService.ts +++ b/packages/api/src/services/weatherService.ts @@ -20,38 +20,64 @@ import endpoints from '../endPoints.js'; interface WeatherParams { lat: number; lon: number; - units?: string; } -interface WeatherData { +export interface WeatherForecastSlot { + dt: number; + tempC: number; + iconCode: string; + pop: number; + rainMm3h: number; + snowMm3h: 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; humidity: number }; + weather: { icon: string }[]; + wind: { speed: number }; + rain?: { '3h'?: number }; + snow?: { '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, + iconCode: entry.weather[0].icon, + pop: entry.pop ?? 0, + rainMm3h: entry.rain?.['3h'] ?? 0, + snowMm3h: entry.snow?.['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..26786decee 100644 --- a/packages/webapp/public/locales/en/translation.json +++ b/packages/webapp/public/locales/en/translation.json @@ -2216,6 +2216,10 @@ "UPLOAD_LAB_DOCUMENT": "Upload lab document", "WILL_SAVE_ONLINE": "Will save when online" }, + "TIME_STRIP": { + "NEXT_TIME_SLOT": "Next time slot", + "PREVIOUS_TIME_SLOT": "Previous time slot" + }, "UNIT": { "TIME": { "DAY": "days", @@ -2245,7 +2249,11 @@ "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", + "PRECIPITATION": "Precipitation", + "TITLE": "Weather & conditions", "WIND": "Wind" }, "WELCOME_SCREEN": { diff --git a/packages/webapp/src/components/TimeStrip/index.tsx b/packages/webapp/src/components/TimeStrip/index.tsx new file mode 100644 index 0000000000..fe822c5a08 --- /dev/null +++ b/packages/webapp/src/components/TimeStrip/index.tsx @@ -0,0 +1,114 @@ +/* + * 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 . + */ + +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 styles from './styles.module.scss'; + +interface TimeStripProps { + slots: { dt: number }[]; + 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', + }); + }, [slots, selectedSlotIndex]); + + return ( +
+ +
+
+ {slots.map((slot, index) => { + const selected = index === selectedSlotIndex; + return ( + + ); + })} +
+
+ +
+ ); +}; + +export function formatTimeChipLabel(utcMs: number, offsetSeconds: number, locale: string): string { + const localMs = (utcMs + 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 default TimeStrip; diff --git a/packages/webapp/src/components/TimeStrip/styles.module.scss b/packages/webapp/src/components/TimeStrip/styles.module.scss new file mode 100644 index 0000000000..3b2fbe01fb --- /dev/null +++ b/packages/webapp/src/components/TimeStrip/styles.module.scss @@ -0,0 +1,102 @@ +/* + * 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; + 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-900); + 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; + justify-content: space-around; + 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: normal; + 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); + font-weight: 700; + box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.05); +} 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..9a52e7c10e --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/DayPillRow/index.tsx @@ -0,0 +1,52 @@ +/* + * 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 . + */ + +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import type { ForecastDay } from '../../../containers/WeatherForecast/utils'; +import styles from './styles.module.scss'; + +export interface DayPillRowProps { + days: ForecastDay[]; + selectedDayIndex: number; + labels: string[]; + onDayClick: (dayIndex: number) => void; +} + +const DayPillRow = ({ days, selectedDayIndex, labels, onDayClick }: DayPillRowProps) => { + const { t } = useTranslation(); + + 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..f66d71f265 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/DayPillRow/styles.module.scss @@ -0,0 +1,102 @@ +/* + * 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 . + */ + +@use '@assets/mixin' as *; + +.row { + display: flex; + gap: 8px; + width: 100%; + margin-bottom: 24px; +} + +.pill { + flex: 1 1 0; + min-width: 0; + height: 72px; + padding: 0 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; + font-weight: 600; + line-height: normal; + 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; + + @media (hover: hover) { + &: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: -16px; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 12px solid transparent; + border-right: 12px solid transparent; + border-top: 16px solid #a1c5be; + } + + &::before { + content: ''; + position: absolute; + left: 50%; + bottom: -15px; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 11px solid transparent; + border-right: 11px solid transparent; + border-top: 15px solid #f2fcfa; + z-index: 1; + } +} + +.frostLabel { + width: 100%; + font-size: 12px; + font-weight: 700; + color: var(--Brand-Accents-colors-and-overlays-Accent-red); + line-height: 1.2; + padding-top: 8px; + border-top: 1px solid var(--Brand-Accents-colors-and-overlays-Accent-red); + + @include truncateText(); +} 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..8d1e953d60 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/FrostBanner/index.tsx @@ -0,0 +1,40 @@ +/* + * 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 . + */ + +import { useTranslation } from 'react-i18next'; +import type { System } from '../../../types'; +import styles from './styles.module.scss'; + +interface FrostBannerProps { + system: System; +} + +export function frostThresholdLabel(system: System): string { + return system === 'imperial' ? '< 36°F' : '< 2°C'; +} + +const FrostBanner = ({ system }: FrostBannerProps) => { + const { t } = useTranslation(); + return ( +
+ {t('WEATHER.FROST_EXPECTED', { + threshold: frostThresholdLabel(system), + interpolation: { escapeValue: false }, + })} +
+ ); +}; + +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..60bf022bef --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/FrostBanner/styles.module.scss @@ -0,0 +1,26 @@ +/* + * 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; + 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: normal; +} diff --git a/packages/webapp/src/components/WeatherForecast/WeatherDetail/index.tsx b/packages/webapp/src/components/WeatherForecast/WeatherDetail/index.tsx new file mode 100644 index 0000000000..54702a06b0 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/WeatherDetail/index.tsx @@ -0,0 +1,80 @@ +/* + * 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 . + */ + +import { useTranslation } from 'react-i18next'; +import type { ForecastDay } from '../../../containers/WeatherForecast/utils'; +import { + convertPrecipitationForDisplay, + convertTempForDisplay, + convertWindForDisplay, + formatLongDate, +} from '../../../containers/WeatherForecast/utils'; +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 type { System } from '../../../types'; +import styles from './styles.module.scss'; + +interface WeatherDetailProps { + day: ForecastDay; + selectedSlot: WeatherForecastSlot; + system: System; + locale: string; +} + +const WeatherDetail = ({ day, selectedSlot, system, locale }: WeatherDetailProps) => { + const { t } = useTranslation(); + const { tempC, windMs, rainMm3h, snowMm3h, humidity, iconCode } = selectedSlot; + const temp = convertTempForDisplay(tempC, system); + const wind = convertWindForDisplay(windMs, system); + const precipitation = convertPrecipitationForDisplay(rainMm3h, snowMm3h, system); + + return ( +
+ {day.isFrost && } +
+ {formatLongDate(day, locale)} +
+ {temp} + +
+
+ +
+ ); +}; + +export default WeatherDetail; diff --git a/packages/webapp/src/components/WeatherForecast/WeatherDetail/styles.module.scss b/packages/webapp/src/components/WeatherForecast/WeatherDetail/styles.module.scss new file mode 100644 index 0000000000..5a5e2e5cf6 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/WeatherDetail/styles.module.scss @@ -0,0 +1,70 @@ +/* + * 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; + gap: 16px; + width: 100%; + margin-bottom: 8px; +} + +.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); +} + +.tempBlock { + display: flex; + align-items: center; + gap: 20px; +} + +.temp { + font-size: 40px; + font-weight: 700; + color: var(--Colors-Primary-Primary-teal-700); + line-height: 1; +} + +.tempBlock :global(.wi) { + font-size: 50px; + color: var(--Colors-Primary-Primary-teal-700); +} + +.metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + color: var(--Colors-Primary-Primary-teal-900); + letter-spacing: 0.4px; + text-align: left; + + dd { + font-size: 16px; + } +} + +.metrics > * { + border-radius: 0; +} diff --git a/packages/webapp/src/components/WeatherForecast/index.tsx b/packages/webapp/src/components/WeatherForecast/index.tsx new file mode 100644 index 0000000000..3b856c120e --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/index.tsx @@ -0,0 +1,97 @@ +/* + * 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 . + */ + +import { useTranslation } from 'react-i18next'; +import type { ForecastDay } from '../../containers/WeatherForecast/utils'; +import type { WeatherForecastSlot } from '../../store/api/types'; +import DayPillRow from './DayPillRow'; +import DayWeatherSummary from './WeatherDetail'; +import TimeStrip from '../TimeStrip'; +import { LoadingSpinner } from '../Loading/LoadingV2'; +import type { System } from '../../types'; +import styles from './styles.module.scss'; + +export interface PureWeatherForecastProps { + isLoading: boolean; + days: ForecastDay[]; + dayPillLabels: string[]; + selectedDayIndex: number; + selectedSlot?: WeatherForecastSlot; + selectedSlotIndex: number; + slots: WeatherForecastSlot[]; + offsetSeconds: number; + system: System; + locale: string; + onDayClick: (dayIndex: number) => void; + onSelectSlot: (slotIndex: number) => void; + onPrev?: () => void; + onNext?: () => void; +} + +const PureWeatherForecast = ({ + isLoading, + days, + dayPillLabels, + selectedDayIndex, + selectedSlot, + selectedSlotIndex, + slots, + offsetSeconds, + system, + locale, + onDayClick, + onSelectSlot, + onPrev, + onNext, +}: PureWeatherForecastProps) => { + const { t } = useTranslation(); + return ( +
+

{t('WEATHER.TITLE')}

+ {isLoading && ( +
+ +
+ )} + {!isLoading && selectedSlot && ( + <> + + + + + )} +
+ ); +}; + +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..98a0fb79e0 --- /dev/null +++ b/packages/webapp/src/components/WeatherForecast/styles.module.scss @@ -0,0 +1,41 @@ +/* + * 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 . + */ + +.card { + display: flex; + flex-direction: column; + width: 100%; + min-height: 413px; + padding: 32px; + background-color: var(--White); + border-radius: 4px; + box-shadow: 0 4px 2px rgba(0, 0, 0, 0.25); +} + +.title { + margin-bottom: 32px; + font-size: 16px; + font-weight: 400; + text-transform: uppercase; + color: var(--Colors-Neutral-Neutral-900); + text-align: left; +} + +.spinner { + display: flex; + justify-content: center; + align-items: center; + flex: 1; +} 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..69cef0616f --- /dev/null +++ b/packages/webapp/src/containers/WeatherForecast/index.tsx @@ -0,0 +1,106 @@ +/* + * 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 . + */ + +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, + localTimeOfDay, + localYmdFromUtcMs, +} from './utils'; +import type { System } from '../../types'; + +export default function WeatherForecast() { + const { i18n } = useTranslation(); + const system = useSelector(measurementSelector) as System; + const { data, isLoading } = useGetWeatherForecastQuery(); + const [selectedSlotIndex, setSelectedSlotIndex] = useState(0); + + const offsetSeconds = data?.city.timezoneOffsetSeconds ?? 0; + + const days = useMemo(() => (data ? groupSlotsByLocalDay(data) : []), [data]); + + const dayPillLabels = useMemo(() => { + const todayLocalYmd = localYmdFromUtcMs(Date.now(), offsetSeconds); + const browserTimezoneOffsetSeconds = -new Date().getTimezoneOffset() * 60; + const offsetMatch = offsetSeconds === browserTimezoneOffsetSeconds; + return days.map((d) => formatDayPillLabel(d, todayLocalYmd, offsetMatch, i18n.language)); + }, [days, offsetSeconds, i18n.language]); + + const selectedDayIndex = useMemo( + () => days.findIndex((d) => d.slotIndices.includes(selectedSlotIndex)), + [days, selectedSlotIndex], + ); + + const selectedSlot = data?.slots[selectedSlotIndex]; + + const activeSlotIndices = days[selectedDayIndex]?.slotIndices ?? []; + const visibleSlots = + data?.slots && activeSlotIndices.length + ? data.slots.slice(activeSlotIndices[0], activeSlotIndices[activeSlotIndices.length - 1] + 1) + : []; + const relativeSelectedSlotIndex = activeSlotIndices.length + ? activeSlotIndices.findIndex((index) => selectedSlotIndex === index) + : 0; + + const handleSelectSlot = (slotIndex: number) => + setSelectedSlotIndex(activeSlotIndices[slotIndex]); + + const onDayClick = (dayIndex: number) => { + if (!selectedSlot) { + return; + } + const currentTime = localTimeOfDay(selectedSlot.dt, offsetSeconds); + const target = days[dayIndex]; + const match = target.slotIndices.find( + (i) => localTimeOfDay(data.slots[i].dt, offsetSeconds) === currentTime, + ); + setSelectedSlotIndex(match ?? target.slotIndices[0]); + }; + + const onPrev = () => setSelectedSlotIndex((i) => Math.max(0, i - 1)); + const onNext = () => { + if (!data?.slots?.length) { + return; + } + setSelectedSlotIndex((i) => Math.min(data.slots.length - 1, i + 1)); + }; + + return ( + + ); +} diff --git a/packages/webapp/src/containers/WeatherForecast/utils.ts b/packages/webapp/src/containers/WeatherForecast/utils.ts new file mode 100644 index 0000000000..8c356bb21d --- /dev/null +++ b/packages/webapp/src/containers/WeatherForecast/utils.ts @@ -0,0 +1,115 @@ +/* + * 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 . + */ + +import { convert } from '../../util/convert-units/convert'; +import i18n from '../../locales/i18n'; +import type { WeatherForecast, WeatherForecastSlot } from '../../store/api/types'; +import type { System } from '../../types'; + +export type ForecastDay = { + localYmd: string; + slotIndices: number[]; + isFrost: boolean; +}; + +export function localYmdFromUtcMs(utcMs: number, offsetSeconds: number): string { + return new Date(utcMs + offsetSeconds * 1000).toISOString().slice(0, 10); +} + +export function localTimeOfDay(utcMs: number, offsetSeconds: number): number { + const localMs = (utcMs + offsetSeconds) * 1000; + const d = new Date(localMs); + return d.getUTCHours() + d.getUTCMinutes() / 60; +} + +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].tempC)); + days.push({ + localYmd, + slotIndices, + isFrost: dayLowC < 2, + }); + } + 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, + offsetMatch: boolean, + locale: string, +): string { + if (offsetMatch && day.localYmd === todayLocalYmd) { + return i18n.t('common:TODAY'); + } + 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); + return date.toLocaleDateString(locale, { + weekday: 'short', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); +} + +export function convertTempForDisplay(tempC: number, system: System): string { + const value = Math.round(system === 'metric' ? tempC : convert(tempC).from('C').to('F')); + const unit = system === 'metric' ? '°C' : '°F'; + return `${value}${unit}`; +} + +export function convertWindForDisplay(windMs: number, system: System): string { + const unit = system === 'metric' ? 'km/h' : 'mph'; + const value = Math.round(convert(windMs).from('m/s').to(unit)); + return `${value} ${unit}`; +} + +export function convertPrecipitationForDisplay( + rainMm: number, + snowMm: number, + system: System, +): string { + const unit = system === 'metric' ? 'mm' : 'in'; + const value = convert(rainMm + snowMm) + .from('mm') + .to(unit); + const displayValue = system === 'metric' ? Math.round(value) : Math.round(value * 10) / 10; + return `${displayValue} ${unit}`; +} diff --git a/packages/webapp/src/store/api/types/index.ts b/packages/webapp/src/store/api/types/index.ts index 09f7c61833..8534ed609c 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; + iconCode: string; + pop: number; + rainMm3h: number; + snowMm3h: 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..f951b70031 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) }), }), }); -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/TimeStrip/TimeStrip.stories.tsx b/packages/webapp/src/stories/TimeStrip/TimeStrip.stories.tsx new file mode 100644 index 0000000000..97438084e5 --- /dev/null +++ b/packages/webapp/src/stories/TimeStrip/TimeStrip.stories.tsx @@ -0,0 +1,60 @@ +/* + * 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 . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import TimeStrip from '../../components/TimeStrip'; +import { componentDecorators } from '../Pages/config/Decorators'; + +const meta: Meta = { + title: 'Components/TimeStrip', + component: TimeStrip, + decorators: componentDecorators, +}; +export default meta; + +type Story = StoryObj; + +const date = new Date(2000, 1, 1, 0, 0, 0, 0); +const slots = Array.from({ length: 8 }, (_, index) => ({ + dt: date.getTime() / 1000 + index * 3 * 3600, +})); + +const Wrapper = (args: { initialIndex: number }) => { + const [selectedSlotIndex, setSelectedSlotIndex] = useState(args.initialIndex); + const isFirstindex = selectedSlotIndex === 0; + const isLastIndex = selectedSlotIndex === slots.length - 1; + return ( + setSelectedSlotIndex((i) => Math.max(0, i - 1))} + onNext={ + isLastIndex + ? undefined + : () => setSelectedSlotIndex((i) => Math.min(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/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..4af951b3c6 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/DayPillRow.stories.tsx @@ -0,0 +1,67 @@ +/* + * 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 . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import DayPillRow from '../../components/WeatherForecast/DayPillRow'; +import { componentDecorators } from '../Pages/config/Decorators'; +import { + formatDayPillLabel, + groupSlotsByLocalDay, + localYmdFromUtcMs, +} from '../../containers/WeatherForecast/utils'; +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 offsetMatch = true; // show "Today" label (assumes browser timezone matches farm) +const labels = days.map((d) => formatDayPillLabel(d, todayYmd, offsetMatch, 'en')); + +const meta: Meta = { + title: 'Components/WeatherForecast/DayPillRow', + component: DayPillRow, + decorators: componentDecorators, +}; +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/FrostBanner.stories.tsx b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx new file mode 100644 index 0000000000..0860830851 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/FrostBanner.stories.tsx @@ -0,0 +1,30 @@ +/* + * 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 . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import FrostBanner from '../../components/WeatherForecast/FrostBanner'; +import { componentDecorators } from '../Pages/config/Decorators'; + +const meta: Meta = { + title: 'Components/WeatherForecast/FrostBanner', + component: FrostBanner, + decorators: componentDecorators, +}; +export default meta; + +type Story = StoryObj; + +export const Metric: Story = { args: { system: 'metric' } }; +export const Imperial: Story = { args: { system: 'imperial' } }; 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..7d22617370 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/PureWeatherForecast.stories.tsx @@ -0,0 +1,125 @@ +/* + * 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 . + */ + +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, + localTimeOfDay, + localYmdFromUtcMs, +} from '../../containers/WeatherForecast/utils'; +import { buildMockForecast } from './mockData'; +import type { System } from '../../types'; + +const meta: Meta = { + title: 'Components/WeatherForecast/PureWeatherForecast', + component: PureWeatherForecast, + decorators: componentDecoratorsWithoutPadding, +}; +export default meta; + +type Story = StoryObj; + +const Wrapper = ({ + frostDayIndex, + initialSlotIndex, + system = 'metric', + isLoading = false, +}: { + frostDayIndex?: number; + initialSlotIndex: number; + system?: System; + isLoading?: boolean; +}) => { + 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 offsetMatch = true; // show "Today" label (assumes browser timezone matches farm) + const labels = days.map((d) => formatDayPillLabel(d, todayYmd, offsetMatch, 'en-US')); + const selectedDayIndex = days.findIndex((d) => d.slotIndices.includes(selectedSlotIndex)); + const selectedSlot = forecast.slots[selectedSlotIndex]; + + const activeSlotIndices = days[selectedDayIndex].slotIndices; + const visibleSlots = forecast.slots.slice( + activeSlotIndices[0], + activeSlotIndices[activeSlotIndices.length - 1] + 1, + ); + const relativeSelectedSlotIndex = activeSlotIndices.findIndex( + (index) => selectedSlotIndex === index, + ); + + const handleSelectSlot = (slotIndex: number) => + setSelectedSlotIndex(activeSlotIndices[slotIndex]); + + const onDayClick = (dayIndex: number) => { + const currentHour = localTimeOfDay(selectedSlot.dt, forecast.city.timezoneOffsetSeconds); + const target = days[dayIndex]; + const match = target.slotIndices.find( + (i) => + localTimeOfDay(forecast.slots[i].dt, forecast.city.timezoneOffsetSeconds) === currentHour, + ); + setSelectedSlotIndex(match ?? target.slotIndices[0]); + }; + + return ( + setSelectedSlotIndex((i) => Math.max(0, i - 1)) + } + onNext={ + selectedSlotIndex === forecast.slots.length - 1 + ? undefined + : () => 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: () => , +}; + +export const Loading: Story = { + render: () => , +}; diff --git a/packages/webapp/src/stories/WeatherForecast/WeatherDetail.stories.tsx b/packages/webapp/src/stories/WeatherForecast/WeatherDetail.stories.tsx new file mode 100644 index 0000000000..5252151f12 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/WeatherDetail.stories.tsx @@ -0,0 +1,59 @@ +/* + * 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 . + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import WeatherDetail from '../../components/WeatherForecast/WeatherDetail'; +import { componentDecorators } from '../Pages/config/Decorators'; +import { groupSlotsByLocalDay } from '../../containers/WeatherForecast/utils'; +import { buildMockForecast } from './mockData'; + +const forecast = buildMockForecast({ frostDayIndex: 2 }); +const days = groupSlotsByLocalDay(forecast); + +const meta: Meta = { + title: 'Components/WeatherForecast/WeatherDetail', + component: WeatherDetail, + decorators: componentDecorators, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + day: days[0], + selectedSlot: forecast.slots[days[0].slotIndices[0]], + system: 'metric', + locale: 'en', + }, +}; + +export const WithFrostBanner: Story = { + args: { + day: days[2], + selectedSlot: forecast.slots[days[2].slotIndices[0]], + system: 'metric', + locale: 'en', + }, +}; + +export const Imperial: Story = { + args: { + day: days[0], + selectedSlot: forecast.slots[days[0].slotIndices[0]], + system: 'imperial', + locale: 'en', + }, +}; diff --git a/packages/webapp/src/stories/WeatherForecast/mockData.ts b/packages/webapp/src/stories/WeatherForecast/mockData.ts new file mode 100644 index 0000000000..d7f69cf749 --- /dev/null +++ b/packages/webapp/src/stories/WeatherForecast/mockData.ts @@ -0,0 +1,63 @@ +/* + * 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 . + */ + +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, + iconCode: '02d', + pop: 0.1, + rainMm3h: 0, + snowMm3h: 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, + iconCode: step >= 3 && step <= 5 ? '10d' : '02d', + rainMm3h: step === 4 ? 1.4 : 0, + snowMm3h: 0, + windMs: 2 + (step % 3), + humidity: 50 + (step % 4) * 5, + }), + ); + } + } + return { + city: { name: 'Vancouver', timezoneOffsetSeconds: OFFSET_SECONDS_VANCOUVER }, + slots, + }; +}