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+
46use crux_core:: capability:: Operation ;
57use crux_core:: macros:: effect;
68use crux_core:: render:: RenderOperation ;
79use 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 ) ]
1218pub 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.
116125fn 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) ]
130155mod 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 ;
0 commit comments