Skip to content

Commit ed1b01f

Browse files
committed
feat: add a stale time check so that we not reload the weather too often
Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
1 parent 60edfcf commit ed1b01f

3 files changed

Lines changed: 101 additions & 25 deletions

File tree

2026-Q2/core/src/lib.rs

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
//! Crux core for the counter+weather PoC.
22
//! Pure Rust, no FFI, no typegen — designed for an in-process Rust shell.
33
4+
use std::time::{Duration, SystemTime};
5+
46
use crux_core::capability::Operation;
57
use crux_core::macros::effect;
68
use crux_core::render::RenderOperation;
79
use crux_core::{App, Command};
810

11+
/// Don't hammer the weather API: skip the fetch if the last reading is
12+
/// fresher than this.
13+
const WEATHER_REFRESH_AFTER: Duration = Duration::from_secs(10);
14+
915
// --- Domain types ---
1016

1117
#[derive(Debug, Clone, PartialEq)]
1218
pub struct WeatherSnapshot {
1319
pub temp_c: f32,
1420
pub condition: WeatherCondition,
15-
pub fetched_at: String, // ISO local timestamp from API
21+
/// Wall-clock time the shell received the response. The shell stamps
22+
/// it; the core only compares it.
23+
pub fetched_at: SystemTime,
1624
}
1725

1826
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -111,12 +119,17 @@ impl App for CounterApp {
111119
}
112120
}
113121

114-
/// Re-rendered after every count change. If the new count is a multiple of 10
115-
/// and we're not already fetching, kick off a weather request.
122+
/// Re-rendered after every count change. If the new count is a non-zero
123+
/// multiple of 10, we're not already fetching, and the last reading is
124+
/// older than [`WEATHER_REFRESH_AFTER`], kick off a new weather request.
116125
fn after_count_change(model: &mut Model) -> Command<Effect, Event> {
117126
use crux_core::render::render;
118127
let render_cmd = render();
119-
if model.count % 10 == 0 && !model.weather_in_flight {
128+
let should_fetch = model.count != 0
129+
&& model.count % 10 == 0
130+
&& !model.weather_in_flight
131+
&& weather_is_stale(model.last_weather.as_ref());
132+
if should_fetch {
120133
model.weather_in_flight = true;
121134
let fetch_cmd =
122135
Command::request_from_shell(FetchMunichWeather).then_send(Event::WeatherLoaded);
@@ -126,15 +139,32 @@ fn after_count_change(model: &mut Model) -> Command<Effect, Event> {
126139
}
127140
}
128141

142+
/// `true` if we have no reading yet, or the existing one is older than the
143+
/// refresh interval. A backwards clock jump also counts as stale (safer).
144+
fn weather_is_stale(last: Option<&WeatherSnapshot>) -> bool {
145+
match last {
146+
None => true,
147+
Some(snap) => SystemTime::now()
148+
.duration_since(snap.fetched_at)
149+
.map(|elapsed| elapsed >= WEATHER_REFRESH_AFTER)
150+
.unwrap_or(true),
151+
}
152+
}
153+
129154
#[cfg(test)]
130155
mod tests {
131156
use super::*;
132157

133158
fn snap(temp: f32) -> WeatherSnapshot {
159+
snap_with_age(temp, Duration::ZERO)
160+
}
161+
162+
/// `age` = how long ago the snapshot was taken (subtracted from `now`).
163+
fn snap_with_age(temp: f32, age: Duration) -> WeatherSnapshot {
134164
WeatherSnapshot {
135165
temp_c: temp,
136166
condition: WeatherCondition::Clear,
137-
fetched_at: "2026-05-06T12:00".into(),
167+
fetched_at: SystemTime::now() - age,
138168
}
139169
}
140170

@@ -212,6 +242,53 @@ mod tests {
212242
assert_eq!(model.last_weather.as_ref().map(|x| x.temp_c), Some(18.3));
213243
}
214244

245+
/// T5 — Throttle: a fresh snapshot suppresses the refetch even when we
246+
/// just crossed into a multiple of 10.
247+
#[test]
248+
fn fetch_is_suppressed_when_last_reading_is_fresh() {
249+
let app = CounterApp;
250+
let mut model = Model {
251+
count: 9,
252+
last_weather: Some(snap(18.3)),
253+
..Default::default()
254+
};
255+
let mut cmd = app.update(Event::Increment, &mut model);
256+
257+
let effects: Vec<Effect> = cmd.effects().collect();
258+
assert!(
259+
!effects
260+
.iter()
261+
.any(|e| matches!(e, Effect::FetchMunichWeather(_))),
262+
"fresh reading should suppress refetch; got {} variants",
263+
effects.len()
264+
);
265+
}
266+
267+
/// T6 — Throttle: once the snapshot is older than the refresh window,
268+
/// crossing a multiple of 10 fetches again.
269+
#[test]
270+
fn fetch_fires_after_refresh_window_elapsed() {
271+
let app = CounterApp;
272+
let mut model = Model {
273+
count: 9,
274+
last_weather: Some(snap_with_age(
275+
18.3,
276+
WEATHER_REFRESH_AFTER + Duration::from_secs(1),
277+
)),
278+
..Default::default()
279+
};
280+
let mut cmd = app.update(Event::Increment, &mut model);
281+
282+
let effects: Vec<Effect> = cmd.effects().collect();
283+
assert!(
284+
effects
285+
.iter()
286+
.any(|e| matches!(e, Effect::FetchMunichWeather(_))),
287+
"stale reading should trigger refetch; got {} variants",
288+
effects.len()
289+
);
290+
}
291+
215292
#[test]
216293
fn weather_loaded_error_clears_in_flight_only() {
217294
let app = CounterApp;

2026-Q2/shell-gpui/src/main.rs

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ impl Render for Shell {
7777
div()
7878
.text_xs()
7979
.text_color(rgb(0x8a96a8))
80-
.child(format_pretty_datetime(&w.fetched_at)),
80+
.child(format_pretty_datetime(w.fetched_at)),
8181
)
8282
.into_any_element(),
8383
(None, false) => div()
@@ -153,24 +153,22 @@ fn emoji_for(c: app_core::WeatherCondition) -> &'static str {
153153
}
154154
}
155155

156-
/// Reformat ISO-ish "2026-05-06T11:30" into "6 May 2026 · 11:30".
157-
/// Robust to truncations: if fields are missing, falls back to the raw string.
158-
fn format_pretty_datetime(iso: &str) -> String {
159-
const MONTHS: [&str; 13] = [
160-
"?", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
161-
];
162-
let Some((date, time)) = iso.split_once('T') else {
163-
return iso.to_string();
156+
/// Render the snapshot's wall-clock age as "12s ago" / "3m ago" / "2h ago".
157+
/// Relative time fits the "this is current weather" framing better than a
158+
/// formatted clock — and dodges the calendar math that would otherwise need
159+
/// a chrono/time dependency.
160+
fn format_pretty_datetime(t: std::time::SystemTime) -> String {
161+
let Ok(elapsed) = std::time::SystemTime::now().duration_since(t) else {
162+
return "just now".to_string();
164163
};
165-
let mut parts = date.split('-');
166-
let (Some(y), Some(m_str), Some(d_str)) = (parts.next(), parts.next(), parts.next()) else {
167-
return iso.to_string();
168-
};
169-
let m: usize = m_str.parse().unwrap_or(0);
170-
let d: u32 = d_str.parse().unwrap_or(0);
171-
let month = MONTHS.get(m).copied().unwrap_or("?");
172-
let hhmm = time.get(..5).unwrap_or(time);
173-
format!("{d} {month} {y} · {hhmm}")
164+
let secs = elapsed.as_secs();
165+
if secs < 60 {
166+
format!("{secs}s ago")
167+
} else if secs < 3_600 {
168+
format!("{}m ago", secs / 60)
169+
} else {
170+
format!("{}h ago", secs / 3_600)
171+
}
174172
}
175173

176174
fn button(

2026-Q2/shell-gpui/src/weather.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! Munich weather fetcher — blocking ureq + minimal JSON parse.
22
//! Lives in the shell because Crux core is forbidden from doing I/O.
33
4+
use std::time::SystemTime;
5+
46
use app_core::{WeatherCondition, WeatherError, WeatherSnapshot};
57
use serde::Deserialize;
68

@@ -16,7 +18,6 @@ struct OpenMeteoResponse {
1618

1719
#[derive(Deserialize)]
1820
struct Current {
19-
time: String,
2021
temperature_2m: f32,
2122
weather_code: u32,
2223
}
@@ -35,7 +36,7 @@ pub fn fetch_munich_weather_blocking() -> Result<WeatherSnapshot, WeatherError>
3536
Ok(WeatherSnapshot {
3637
temp_c: parsed.current.temperature_2m,
3738
condition: classify(parsed.current.weather_code),
38-
fetched_at: parsed.current.time,
39+
fetched_at: SystemTime::now(),
3940
})
4041
}
4142

0 commit comments

Comments
 (0)