roughing in pollen

This commit is contained in:
Matthew Ryan Dillon 2025-05-10 18:51:49 -04:00
parent 45ff2ee635
commit 024956a12b
6 changed files with 534 additions and 64 deletions

View file

@ -13,6 +13,7 @@ pub struct Config {
pub calendar_event_count: usize,
pub location: String,
pub timezone: String,
pub pollen_zip: String,
}
impl Config {

View file

@ -1,10 +1,12 @@
mod config;
mod pollen;
mod weather;
use chrono::{TimeZone, Utc};
use chrono::{TimeZone, Utc, DateTime};
use chrono_tz::Tz;
use config::Config;
use weather::{fetch_weather, WeatherSummary};
use pollen::fetch_pollen_api;
use weather::{fetch_weather, geocode_city, WeatherSummary};
#[tokio::main]
async fn main() {
@ -24,7 +26,15 @@ async fn main() {
None
}
};
if let Some(w) = weather {
let (_lat, _lon) = if weather.is_some() {
match geocode_city(&config.location, &config.weather_api_key).await {
Ok((lat, lon)) => (lat, lon),
Err(_) => (0.0, 0.0),
}
} else {
(0.0, 0.0)
};
if let Some(w) = &weather {
if w.obs_time_unix > 0 {
match config.timezone.parse::<Tz>() {
Ok(tz) => {
@ -54,8 +64,50 @@ async fn main() {
println!("Weather: N/A");
}
let pollen = get_pollen_count(&config);
println!("\nPollen count: {pollen}");
match fetch_pollen_api(&config.pollen_zip).await {
Ok(p) => {
// Format pollen forecast date to match weather, in config.timezone
if let Ok(dt) = DateTime::parse_from_rfc3339(&p.forecast_date) {
match config.timezone.parse::<Tz>() {
Ok(tz) => {
let local_time = dt.with_timezone(&tz);
println!(
"\nPollen.com ({})\n Forecast for {}:",
p.location,
local_time.format("%Y-%m-%d %H:%M:%S %Z")
);
}
Err(_) => {
println!(
"\nPollen.com ({})\n Forecast for {} (timezone parse error):",
p.location, p.forecast_date
);
}
}
} else {
println!(
"\nPollen.com ({})\n Forecast for {}:",
p.location, p.forecast_date
);
}
for day in &["Yesterday", "Today", "Tomorrow"] {
if let Some(period) = p.periods.get(*day) {
println!(
" {:9}: {:>4.1} ({})",
day,
period.index,
period.triggers.join(", ")
);
} else {
println!(" {:9}: N/A", day);
}
}
}
Err(e) => {
println!("\nFailed to fetch pollen (API): {e}");
}
}
println!();
println!("Upcoming calendar events:");
@ -72,14 +124,8 @@ async fn main() {
}
}
// Placeholder stubs, replace with actual API calls later
fn get_pollen_count(_config: &Config) -> String {
// TODO: Call pollen count API
"Medium (Tree: 5, Grass: 2, Weed: 1)".to_string()
}
// Placeholder stubs
fn get_calendar_events(_config: &Config, n: usize) -> Vec<String> {
// TODO: Call calendar API
vec![
"Team meeting at 10:00 AM".to_string(),
"Lunch with Alex at 12:30 PM".to_string(),
@ -91,7 +137,6 @@ fn get_calendar_events(_config: &Config, n: usize) -> Vec<String> {
}
fn get_shopify_packages(_config: &Config) -> Vec<String> {
// TODO: Call Shopify API
vec![
"Order #1234: Shipped - Arriving May 13".to_string(),
"Order #5678: In transit - Arriving May 15".to_string(),

79
src/pollen.rs Normal file
View file

@ -0,0 +1,79 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::error::Error;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PollenApiResponse {
forecast_date: String,
location: Option<Location>,
}
#[derive(Debug, Deserialize)]
struct Location {
periods: Vec<Period>,
#[serde(rename = "DisplayLocation")]
display_location: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Period {
r#type: String,
index: f32,
triggers: Vec<Trigger>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Trigger {
name: String,
#[allow(dead_code)]
plant_type: String,
#[allow(dead_code)]
genus: String,
}
pub struct PollenPeriod {
pub index: f32,
pub triggers: Vec<String>,
}
pub struct PollenSummary {
pub location: String,
pub forecast_date: String,
pub periods: HashMap<String, PollenPeriod>, // "Yesterday", "Today", "Tomorrow"
}
pub async fn fetch_pollen_api(zip: &str) -> Result<PollenSummary, Box<dyn Error>> {
let url = format!("https://www.pollen.com/api/forecast/current/pollen/{}", zip);
let resp = reqwest::Client::new()
.get(&url)
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")
.header("Accept", "application/json, text/plain, */*")
.header("Referer", &url)
.header("Cookie", format!("geo={}", zip))
.send().await?
.text().await?;
let api: PollenApiResponse = serde_json::from_str(&resp)?;
let loc = api.location.ok_or("No location in pollen.com response")?;
let mut periods = HashMap::new();
for period in &loc.periods {
let triggers: Vec<String> = period.triggers.iter().map(|t| t.name.clone()).collect();
periods.insert(
period.r#type.clone(),
PollenPeriod {
index: period.index,
triggers,
},
);
}
Ok(PollenSummary {
location: loc.display_location,
forecast_date: api.forecast_date,
periods,
})
}

View file

@ -1,12 +1,47 @@
use serde::Deserialize;
use std::error::Error;
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct GeocodeResponse {
lat: f64,
lon: f64,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
country: String,
}
// returns (lat, lon) for specified city name
pub async fn geocode_city(city: &str, api_key: &str) -> Result<(f64, f64), Box<dyn Error>> {
let client = reqwest::Client::new();
let geocode_url = format!(
"https://api.openweathermap.org/geo/1.0/direct?q={}&limit=1&appid={}",
city, api_key
);
let geo_resp = client.get(&geocode_url).send().await?;
let geo_text = geo_resp.text().await?;
let geo_parsed: Result<Vec<GeocodeResponse>, serde_json::Error> =
serde_json::from_str(&geo_text);
match geo_parsed {
Ok(geo_vec) => {
if geo_vec.is_empty() {
return Err(format!(
"No geocoding result for city: {} (response: {})",
city, geo_text
)
.into());
}
Ok((geo_vec[0].lat, geo_vec[0].lon))
}
Err(e) => {
eprintln!("Failed to decode geocoding response. Raw response: {geo_text}");
Err(Box::new(e))
}
}
}
// ------------------ WEATHER -------------------------
#[derive(Debug, Deserialize)]
pub struct WeatherCurrent {
pub temp: f64,
@ -46,33 +81,11 @@ pub struct WeatherSummary {
pub obs_time_unix: i64,
}
/// Returns WeatherSummary struct
pub async fn fetch_weather(city: &str, api_key: &str) -> Result<WeatherSummary, Box<dyn Error>> {
let (lat, lon) = geocode_city(city, api_key).await?;
let client = reqwest::Client::new();
let geocode_url = format!(
"https://api.openweathermap.org/geo/1.0/direct?q={}&limit=1&appid={}",
city, api_key
);
let geo_resp = client.get(&geocode_url).send().await?;
let geo_text = geo_resp.text().await?;
let geo_parsed: Result<Vec<GeocodeResponse>, serde_json::Error> =
serde_json::from_str(&geo_text);
let (lat, lon) = match geo_parsed {
Ok(geo_vec) => {
if geo_vec.is_empty() {
return Err(format!(
"No geocoding result for city: {} (response: {})",
city, geo_text
)
.into());
}
(geo_vec[0].lat, geo_vec[0].lon)
}
Err(e) => {
eprintln!("Failed to decode geocoding response. Raw response: {geo_text}");
return Err(Box::new(e));
}
};
// Get weather data from One Call 3.0
let onecall_url = format!(
"https://api.openweathermap.org/data/3.0/onecall?lat={}&lon={}&appid={}&units=imperial",
lat, lon, api_key
@ -92,6 +105,7 @@ pub async fn fetch_weather(city: &str, api_key: &str) -> Result<WeatherSummary,
}
};
// Current conditions
let temp = format!("{:.1}°F", one_parsed.current.temp);
let current_desc = one_parsed
.current