Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4982431
LF-5298: Replace Home weather widget with 5-day forecast
litefarm-pr-bot May 21, 2026
73fa20b
LF-5298 Restore comment in weatherApi
SayakaOno May 22, 2026
3d0a8db
LF-5298 Improve formatLongDate function
SayakaOno May 22, 2026
5c80652
LF-5298 Update TimeStrip CSS
SayakaOno May 25, 2026
eeb4221
LF-5298 Update TimeStrip stories decorator
SayakaOno May 25, 2026
571f80e
LF-5298 Adjust FrostBanner component and stories
SayakaOno May 25, 2026
8f53bab
LF-5298 Adjust DayWeatherSummary component
SayakaOno May 25, 2026
e095b44
LF-5298 Adjust DayPillRow
SayakaOno May 25, 2026
bef6a55
LF-5298 Adjust weather widget spacing
SayakaOno May 25, 2026
56f6b4c
LF-5298 Adjust weather forcast
SayakaOno May 26, 2026
ad28122
LF-5298 [WIP] Update formatDayPillLabel to check browser's time offse…
SayakaOno May 26, 2026
4822b73
LF-5298 Update TimeStrip to check onPrev and onNextand disable arrows
SayakaOno May 26, 2026
35cdf98
LF-5298 Update TimeStrip and stories
SayakaOno May 26, 2026
4134316
LF-5298 Update PureWeatherForecast to show slots for selected day
SayakaOno May 26, 2026
c244efe
LF-5298 Make TimeStrip reusable
SayakaOno May 26, 2026
2fe3164
LF-5298 Add justify-content: space-around to TimeStrip slotsRow
SayakaOno May 26, 2026
cb61e0d
LF-5298 Move TimeStrip component and stories
SayakaOno May 26, 2026
c2bcce7
LF-5298 Use existing translation
SayakaOno May 26, 2026
3e9dafe
LF-5298 Replace Measurement with System
SayakaOno May 26, 2026
1988a2d
LF-5298 Rename selectors file to utils
SayakaOno May 26, 2026
ecc95cc
LF-5298 Move frostThresholdLabel function
SayakaOno May 26, 2026
bf0c8ba
LF-5298 Finalize formatDayPillLabel function
SayakaOno May 27, 2026
8e8207e
LF-5298 Remove tempMinC from Weather API response, use tempC for fros…
SayakaOno May 27, 2026
514ca9e
LF-5298 Include snow data in precipitation
SayakaOno May 27, 2026
69c5b49
LF-5298 Tweaks
SayakaOno May 27, 2026
1e715f9
LF-5298 Move logic in PureWeatherForecast to its parent
SayakaOno May 27, 2026
012ad4d
LF-5298 Rename DayWeatherSummary to WeatherDetail
SayakaOno May 27, 2026
8c71c40
LF-5298 Remove temp_min from OpenWeatherListEntry
SayakaOno May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/api/src/controllers/weatherController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/endPoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 46 additions & 20 deletions packages/api/src/services/weatherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WeatherData> {
async getWeather({ lat, lon }: WeatherParams): Promise<WeatherForecast> {
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<OpenWeatherForecastResponse>(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;
Expand Down
8 changes: 8 additions & 0 deletions packages/webapp/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
114 changes: 114 additions & 0 deletions packages/webapp/src/components/TimeStrip/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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<HTMLButtonElement | null>(null);

useEffect(() => {
selectedRef.current?.scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'nearest',
});
}, [slots, selectedSlotIndex]);

return (
<div className={styles.strip}>
<button
type="button"
className={styles.chevron}
onClick={onPrev}
disabled={!onPrev}
aria-label={t('TIME_STRIP.PREVIOUS_TIME_SLOT')}
>
<ChevronLeft />
</button>
<div className={styles.slotsScroll}>
<div className={styles.slotsRow}>
{slots.map((slot, index) => {
const selected = index === selectedSlotIndex;
return (
<button
key={slot.dt}
ref={selected ? selectedRef : undefined}
type="button"
aria-pressed={selected}
className={clsx(styles.chip, selected && styles.selected)}
onClick={() => onSelect(index)}
>
{formatTimeChipLabel(slot.dt, offsetSeconds, locale)}
</button>
);
})}
</div>
</div>
<button
type="button"
className={styles.chevron}
onClick={onNext}
disabled={!onNext}
aria-label={t('TIME_STRIP.NEXT_TIME_SLOT')}
>
<ChevronRight />
</button>
</div>
);
};

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;
102 changes: 102 additions & 0 deletions packages/webapp/src/components/TimeStrip/styles.module.scss
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

.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);
}
Loading
Loading