diff --git a/AGENTS.md b/AGENTS.md index faa8482..6acc041 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ decodes the JWT token for user ID, queries the GraphQL API for workout data, and - Weights: #FF7900 (255,121,0) - Reps: #00BBF9 (0,187,249) - Sets: #F15BB5 (241,91,181) -- **Performance**: Reuse HTTP client across requests to maintain connection pooling and avoid TCP overhead. Session-level caching of user preferences. Fetch packs up to 10 `jday` queries per GraphQL request (aliases) and runs 8 requests concurrently (`JDAY_BATCH_SIZE=10`, `FETCH_CONCURRENCY=8`). Sequential `jday` fetching of 152 workouts took ~19s; batched+concurrent takes ~1.2s for the workout downloads (~3.4s including date listing/auth). HTTP client uses a larger idle pool, TCP_NODELAY, and gzip. +- **Performance**: Reuse HTTP client across requests to maintain connection pooling and avoid TCP overhead. Session-level caching of user preferences (`user_wants_kg`); `getSession` is lazy, not on every command. Fetch packs up to 10 `jday` queries per GraphQL request (aliases) and runs 8 requests concurrently (`JDAY_BATCH_SIZE=10`, `FETCH_CONCURRENCY=8`). Bounded `jrange` listing (`get_dates` with both oldest and latest, e.g. `table deadlift 2026`) splits the span into independent 32-week windows and fetches them concurrently (`jrange_windows`). `table` / `heatmap` / filtered `list` load workout bodies via `get_jdays` (cache hits stay local; misses use the same batched path as `fetch`) instead of one GraphQL request per date. Sequential `jday` fetching of 152 workouts took ~19s; batched+concurrent takes ~1.2s for the workout downloads. Network slowness on a warm cache is date listing + auth, not parsing. HTTP client uses a larger idle pool, TCP_NODELAY, and gzip. - **Rust Implementation**: Used reqwest for HTTP with client reuse, serde for JSON, base64 for JWT decoding, ansi_term for colors, atty for TTY detection. Handled GraphQL responses, error checking, and inline color application during text generation. ## Referenced Links @@ -175,6 +175,7 @@ Recent refactoring extracted common code into helper functions to improve mainta - **Common Helpers**: - `workouts::resolve_user_wants_kg`: Consolidates logic for determining user weight unit preference, checking network token then falling back to cache. - `workouts::get_dates_from_ranges`: Unified logic for parsing date ranges and fetching/calculating dates, used by both `list` and `fetch` commands. + - `workouts::jrange_windows` / `fetch_jrange_windows`: Split a bounded date range into concurrent `jrange` week-windows (max 32 weeks each). - `workouts::format_cached_jday_text` / `read_cached_jday_text`: Cache file text used by `write_cached_jday` and `fetch --diff`. - `fetch::format_text_diff`: Unified-style diff; returns `None` when local and server texts are identical. - `utils::create_progress_bar`: Standardized progress bar creation using `indicatif`. @@ -203,7 +204,7 @@ Unlike the C version which shows separate tables per filter, the Rust implementa ## Future Improvements -- Parallelize `get_dates` jrange pagination (currently sequential week-windows). +- Parallelize unbounded `get_dates` jrange pagination (bounded ranges already use concurrent windows; full-history listing is still sequential). - Optionally use `downloadLogs` for full-history `fetch` with no date filter. - Add support for year/month range queries. - Add export options (JSON, CSV). diff --git a/README.md b/README.md index 8f45a0e..984f1fd 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ Display a progression table showing personal records (PRs) over time for specifi - Filter by date range: `wxrust table 2025` - Combine date and exercise filters: `wxrust table 2025 deadlift` +`table` (and `heatmap` / filtered `list`) list dates with concurrent `jrange` windows, then load workouts through the same batched `jday` path as `fetch`. Cached days stay local; `--no-network` skips the date listing round-trip entirely. + Arguments are automatically classified as dates or exercise filters: - **Date formats**: `YYYY`, `YYYY-MM`, `YYYY.MM`, `YYYYMM`, `YYYY-MM-DD`, `YYYY.MM.DD`, `YYYYMMDD` - **Everything else**: Treated as exercise name filter (case-insensitive substring match) @@ -182,4 +184,4 @@ wxrust --color never list --summary --count 1 ## API Details -Interacts with WeightXReps GraphQL API at `https://weightxreps.net/api/graphql`. Uses `login` mutation for auth, `jrange` query for date ranges, and `JDay` query for individual workouts. Supports efficient connection reuse for multiple requests. +Interacts with WeightXReps GraphQL API at `https://weightxreps.net/api/graphql`. Uses `login` mutation for auth, `jrange` query for date ranges (up to 32 weeks per request; bounded ranges fire independent windows concurrently), and `JDay` query for individual workouts (up to 10 dates per request, 8 requests in flight). Cached unit preference avoids an extra `getSession` on every run. Supports efficient connection reuse for multiple requests. diff --git a/src/heatmap.rs b/src/heatmap.rs index 6d403fd..f780a35 100644 --- a/src/heatmap.rs +++ b/src/heatmap.rs @@ -75,9 +75,7 @@ pub fn compute_metric(jday: &JDay, metric: Metric, filters: &[String]) -> f64 { } /// Handle the heatmap command -pub async fn handle_heatmap( - client: &C, - token: &Option, +pub async fn handle_heatmap( data_access: DataAccess<'_, C>, metric: Metric, green: bool, @@ -136,61 +134,22 @@ pub async fn handle_heatmap( } } - // Fetch workouts asynchronously - let (tx, mut rx) = tokio::sync::mpsc::channel(32); - - for date in dates.iter() { - let date = date.clone(); - let client_clone = client.clone(); - let token_clone = token.clone(); - let use_network = data_access.use_network; - let use_cache = data_access.use_cache; - let write_cache = data_access.write_cache; - let uid = data_access.uid; - let tx_clone = tx.clone(); - - tokio::spawn(async move { - let data_access_clone = crate::api::DataAccess { - client: &client_clone, - token: token_clone.as_deref(), - uid, - use_network, - use_cache, - write_cache, - }; - let result = match workouts::get_jday(&data_access_clone, &date, verbose).await { - Ok(jday) => Some(jday), - Err(e) => { - if verbose { - eprintln!("Error getting workout for {}: {}", date, e); - } - None - } - }; - let _ = tx_clone.send((date.clone(), result)).await; - }); - } - drop(tx); - - // Collect results - let mut results = Vec::new(); - while let Some(result) = rx.recv().await { - results.push(result); - } - - // Sort by date - results.sort_by(|a, b| a.0.cmp(&b.0)); + let results = match workouts::get_jdays(&data_access, &dates, verbose).await { + Ok(v) => v, + Err(e) => { + utils::exit_with_error(format!("Failed to get workouts: {}", e)); + } + }; // Compute daily metrics let mut daily_values: HashMap = HashMap::new(); - for (date_str, jday_opt) in results { - if let Some(jday) = jday_opt - && let Ok(date) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { - let value = compute_metric(&jday, metric, &filters); - if value > 0.0 { - daily_values.insert(date, value); - } + for (date_str, jday) in results { + if let Ok(date) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { + let value = compute_metric(&jday, metric, &filters); + if value > 0.0 { + daily_values.insert(date, value); } + } } if daily_values.is_empty() { diff --git a/src/list.rs b/src/list.rs index e670bbf..180b851 100644 --- a/src/list.rs +++ b/src/list.rs @@ -1,15 +1,12 @@ -use crate::api::ReqwestClient; -use crate::models; +use crate::api::ApiClient; use crate::workouts; use crate::utils; use crate::formatters; use crate::table::{parse_date_and_filter_arguments, matches_any_filter}; -pub async fn handle_list( +pub async fn handle_list( list: &crate::ListArgs, - client: &ReqwestClient, - token: &Option, - data_access: crate::api::DataAccess<'_, ReqwestClient>, + data_access: crate::api::DataAccess<'_, C>, verbose: bool, ) { let (date_args, filters) = parse_date_and_filter_arguments(&list.args); @@ -45,136 +42,52 @@ pub async fn handle_list( utils::exit_with_error("No workouts found in the specified range"); } - // If filters are present, we need to filter dates based on workout content - let filtered_dates = if filters.is_empty() { - dates_to_use - } else { - // Need to fetch workouts to check for matching exercises - let _user_wants_kg = workouts::resolve_user_wants_kg(&data_access).await; - let (tx, mut rx) = tokio::sync::mpsc::channel(32); - let filters_clone = filters.clone(); - for (seq, date) in dates_to_use.iter().enumerate() { - let date = date.clone(); - let client_clone = client.clone(); - let token_clone = token.clone(); - let verbose = verbose; - let use_network = data_access.use_network; - let use_cache = data_access.use_cache; - let write_cache = data_access.write_cache; - let uid = data_access.uid; - let tx_clone = tx.clone(); - let filters_clone = filters_clone.clone(); - tokio::spawn(async move { - let data_access_clone = crate::api::DataAccess { - client: &client_clone, - token: token_clone.as_deref(), - uid, - use_network, - use_cache, - write_cache, - }; - let result = match workouts::get_jday(&data_access_clone, &date, verbose).await { - Ok(jday) => { - // Check if any exercise matches the filters - let mut has_match = false; - for eblock in &jday.eblocks { - if let Some(ex) = jday.exercises.iter().find(|ex_wrap| ex_wrap.exercise.id == eblock.eid) { - if matches_any_filter(&ex.exercise.name, &filters_clone) { - has_match = true; - break; - } - } - } - if has_match { Some(date.clone()) } else { None } - } - Err(e) => { - eprintln!("Error getting workout for {}: {}", date, e); - None - } - }; - tx_clone.send((seq, result)).await.unwrap(); - }); - } - drop(tx); - use std::collections::BTreeMap; - let mut buffer: BTreeMap> = BTreeMap::new(); - let mut filtered = Vec::new(); - let mut next_seq = 0; - while let Some((seq, result)) = rx.recv().await { - buffer.insert(seq, result); - while let Some(maybe_date) = buffer.remove(&next_seq) { - if let Some(date) = maybe_date { - filtered.push(date); - } - next_seq += 1; - } + if filters.is_empty() && !list.details && !list.summary { + for date in dates_to_use { + println!("{}", date); } - filtered + return; + } + + let workouts = match workouts::get_jdays(&data_access, &dates_to_use, verbose).await { + Ok(w) => w, + Err(e) => utils::exit_with_error(e), }; - if filtered_dates.is_empty() { + let workouts: Vec<_> = if filters.is_empty() { + workouts + } else { + workouts.into_iter().filter(|(_, jday)| { + jday.eblocks.iter().any(|eblock| { + jday.exercises.iter().any(|ex_wrap| { + ex_wrap.exercise.id == eblock.eid + && matches_any_filter(&ex_wrap.exercise.name, &filters) + }) + }) + }).collect() + }; + + if workouts.is_empty() { utils::exit_with_error("No workouts found matching the specified filters"); } if list.details || list.summary { let user_wants_kg = workouts::resolve_user_wants_kg(&data_access).await; - let (tx, mut rx) = tokio::sync::mpsc::channel(32); - for (seq, date) in filtered_dates.iter().enumerate() { - let date = date.clone(); - let client_clone = client.clone(); - let token_clone = token.clone(); - let verbose = verbose; - let use_network = data_access.use_network; - let use_cache = data_access.use_cache; - let write_cache = data_access.write_cache; - let uid = data_access.uid; - let tx_clone = tx.clone(); - tokio::spawn(async move { - let data_access_clone = crate::api::DataAccess { - client: &client_clone, - token: token_clone.as_deref(), - uid, - use_network, - use_cache, - write_cache, - }; - let result = match workouts::get_jday(&data_access_clone, &date, verbose).await { - Ok(jday) => Some(jday), - Err(e) => { - eprintln!("Error getting workout for {}: {}", date, e); - None - } - }; - tx_clone.send((seq, date.clone(), result)).await.unwrap(); - }); - } - drop(tx); - use std::collections::BTreeMap; - let mut buffer: BTreeMap)> = BTreeMap::new(); - let mut next_seq = 0; - while let Some((seq, new_date, new_jday)) = rx.recv().await { - buffer.insert(seq, (new_date, new_jday)); - while let Some((date, result)) = buffer.remove(&next_seq) { - let jday = match result { - Some(jday) => jday, - _ => continue - }; - if list.details { - let workout = formatters::format_workout(&date, &jday, user_wants_kg); - println!("{}", workout); - if !workout.ends_with('\n') { - println!(); - } - } else if list.summary { - let fmt_date = formatters::color_date(&date); - let summary = formatters::summarize_workout(&jday, user_wants_kg, &filters); - println!("{} {}", fmt_date, summary); + for (date, jday) in workouts { + if list.details { + let workout = formatters::format_workout(&date, &jday, user_wants_kg); + println!("{}", workout); + if !workout.ends_with('\n') { + println!(); } - next_seq += 1; + } else { + let fmt_date = formatters::color_date(&date); + let summary = formatters::summarize_workout(&jday, user_wants_kg, &filters); + println!("{} {}", fmt_date, summary); } } } else { - for date in filtered_dates { + for (date, _) in workouts { println!("{}", date); } } diff --git a/src/main.rs b/src/main.rs index afa69f3..d0f2d4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod list; use clap::{Parser, Subcommand}; use wxrust::credentials; -use crate::api::{ReqwestClient, ApiClient}; +use crate::api::ReqwestClient; #[derive(Parser)] #[command(name = "wxrust")] @@ -93,10 +93,7 @@ async fn setup_auth_and_data_access( Ok(claims) => claims.id, Err(e) => utils::exit_with_error(format!("Failed to decode token: {}", e)), }; - let _user = match client.get_user_info(&token).await { - Ok(u) => u, - Err(e) => utils::exit_with_error(e), - }; + // Unit preference is loaded lazily (cached, or getSession on first use). (Some(token), Some(uid)) }; @@ -321,7 +318,7 @@ async fn main() -> Result<(), Box> { match args.command { Commands::List(list) => { - list::handle_list(&list, &client, &token, data_access, args.verbose).await; + list::handle_list(&list, data_access, args.verbose).await; }, Commands::Show(show) => { handle_show(&show, data_access, args.verbose).await; @@ -330,7 +327,7 @@ async fn main() -> Result<(), Box> { handle_fetch(&fetch_args, data_access, args.verbose).await; }, Commands::Table(table_args) => { - table::handle_table(&client, &token, data_access, &table_args.args, &table_args.dream, args.verbose).await; + table::handle_table(data_access, &table_args.args, &table_args.dream, args.verbose).await; }, Commands::Heatmap(heatmap_args) => { // Determine metric - default to OneRm @@ -349,8 +346,6 @@ async fn main() -> Result<(), Box> { }; heatmap::handle_heatmap( - &client, - &token, data_access, metric, heatmap_args.green, diff --git a/src/table.rs b/src/table.rs index 3f8e1fa..1e28bef 100644 --- a/src/table.rs +++ b/src/table.rs @@ -489,9 +489,7 @@ pub fn format_table(state: &TableState, filters: &[String], user_wants_kg: bool) // ============================================================================ /// Handle the table command -pub async fn handle_table( - client: &C, - token: &Option, +pub async fn handle_table( data_access: DataAccess<'_, C>, args: &[String], dreams: &Vec, @@ -509,21 +507,23 @@ pub async fn handle_table( } } - // Get dates to process - let dates = if date_args.is_empty() { - // Default: get all dates from cache/server - match workouts::get_dates(&data_access, None, None, 10000, false).await { - Ok(d) => d, - Err(e) => { - utils::exit_with_error(format!("Failed to get dates: {}", e)); - } + // Date listing and unit preference can overlap (jrange vs cached/getSession). + let dates_fut = async { + if date_args.is_empty() { + workouts::get_dates(&data_access, None, None, 10000, false).await + } else { + workouts::get_dates_from_ranges(&data_access, &date_args).await } - } else { - match workouts::get_dates_from_ranges(&data_access, &date_args).await { - Ok(d) => d, - Err(e) => { - utils::exit_with_error(format!("Failed to get dates from ranges: {}", e)); - } + }; + let (dates_result, user_wants_kg) = tokio::join!( + dates_fut, + workouts::resolve_user_wants_kg(&data_access), + ); + + let dates = match dates_result { + Ok(d) => d, + Err(e) => { + utils::exit_with_error(format!("Failed to get dates: {}", e)); } }; @@ -540,49 +540,17 @@ pub async fn handle_table( } } - // Fetch workouts asynchronously (like handle_list) - let user_wants_kg = workouts::resolve_user_wants_kg(&data_access).await; - let (tx, mut rx) = tokio::sync::mpsc::channel(32); - - for date in dates.iter() { - let date = date.clone(); - let client_clone = client.clone(); - let token_clone = token.clone(); - let use_network = data_access.use_network; - let use_cache = data_access.use_cache; - let write_cache = data_access.write_cache; - let uid = data_access.uid; - let tx_clone = tx.clone(); - //let verbose = verbose; - - tokio::spawn(async move { - let data_access_clone = crate::api::DataAccess { - client: &client_clone, - token: token_clone.as_deref(), - uid, - use_network, - use_cache, - write_cache, - }; - let result = match workouts::get_jday(&data_access_clone, &date, verbose).await { - Ok(jday) => Some(jday), - Err(e) => { - if verbose { - eprintln!("Error getting workout for {}: {}", date, e); - } - None - } - }; - let _ = tx_clone.send((date.clone(), result)).await; - }); - } - drop(tx); - - // Collect results and process in date order for deterministic PR tracking - let mut results = Vec::new(); - while let Some(result) = rx.recv().await { - results.push(result); - } + // Cached dates stay local; missing dates are fetched in batched concurrent jday requests. + let fetched = match workouts::get_jdays(&data_access, &dates, verbose).await { + Ok(v) => v, + Err(e) => { + utils::exit_with_error(format!("Failed to get workouts: {}", e)); + } + }; + let mut results: Vec<(String, Option)> = fetched + .into_iter() + .map(|(date, jday)| (date, Some(jday))) + .collect(); // Process each dream for dream in dreams { diff --git a/src/workouts.rs b/src/workouts.rs index 5ecc050..dccdec1 100644 --- a/src/workouts.rs +++ b/src/workouts.rs @@ -2,7 +2,7 @@ use crate::api; use crate::formatters; use crate::models; use crate::parsers; -use chrono::{Datelike, Utc}; +use chrono::{Datelike, Duration, NaiveDate, Utc}; use futures::StreamExt; use lazy_static::lazy_static; use std::collections::HashMap; @@ -14,6 +14,18 @@ use std::sync::Mutex; pub const JDAY_BATCH_SIZE: usize = 10; /// Maximum number of batched GraphQL requests in flight during a bulk fetch. pub const FETCH_CONCURRENCY: usize = 8; +/// `jrange.range` is weeks; the API rejects values above this (32 weeks ≈ 224 days). +pub const JRANGE_MAX_WEEKS: i32 = 32; + +const JRANGE_QUERY: &str = r#" +query GetJRange($uid: ID!, $ymd: YMD!, $range: Int!) { + jrange(uid: $uid, ymd: $ymd, range: $range) { + days { + on + } + } +} +"#; const JDAY_FIELDS: &str = r#" log bw @@ -137,6 +149,49 @@ pub fn chunk_dates(dates: &[String], batch_size: usize) -> Vec> { dates.chunks(batch_size).map(|c| c.to_vec()).collect() } +fn parse_ymd(s: &str) -> Option { + NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() +} + +fn ymd_from_jrange_on(d: &str) -> Option { + if d.len() >= 10 { + Some(format!("{}-{}-{}", &d[0..4], &d[5..7], &d[8..10])) + } else { + None + } +} + +fn dates_from_jrange_days(days: Vec) -> Vec { + days.into_iter() + .filter_map(|day| day.on) + .filter_map(|d| ymd_from_jrange_on(&d)) + .collect() +} + +/// Split `[oldest, latest]` into `jrange` windows of at most `JRANGE_MAX_WEEKS`. +/// +/// `jrange(ymd, range)` returns days between `ymd - range*7` and `ymd`. +/// Adjacent windows overlap by one day so a boundary workout cannot be missed. +pub fn jrange_windows(oldest: NaiveDate, latest: NaiveDate) -> Vec<(String, i32)> { + if latest < oldest { + return vec![]; + } + let mut windows = Vec::new(); + let mut end = latest; + loop { + let days = (end - oldest).num_days() + 1; + let weeks = (((days + 6) / 7) as i32).clamp(1, JRANGE_MAX_WEEKS); + windows.push((end.format("%Y-%m-%d").to_string(), weeks)); + let covered_start = end - Duration::days(weeks as i64 * 7); + if covered_start <= oldest { + break; + } + // Overlap the boundary day; the next window ends on this window's start. + end = covered_start; + } + windows +} + pub fn build_jday_query(uid: u32, date: &str) -> String { format!( "query {{\n jday(uid: {}, ymd: \"{}\") {{\n{}\n }}\n}}\n", @@ -416,14 +471,81 @@ where Ok(ordered) } +async fn fetch_jrange( + data_access: &crate::api::DataAccess<'_, C>, + uid: u32, + token: &str, + ymd: &str, + range: i32, +) -> Result, String> { + let variables = serde_json::json!({ "uid": uid.to_string(), "ymd": ymd, "range": range }); + let response: models::GraphQLResponse = + api::graphql_request(data_access.client, token, JRANGE_QUERY, Some(variables)) + .await + .map_err(|e| e.to_string())?; + + if let Some(errors) = response.errors { + return Err(errors.into_iter().map(|e| e.message).collect::>().join("; ")); + } + + let days = if let Some(data) = response.data { + if let Some(jrange) = data.jrange { + jrange.days.unwrap_or_default() + } else { + vec![] + } + } else { + return Err("Unexpected response.".to_string()); + }; + + Ok(dates_from_jrange_days(days)) +} + +/// Fetch workout dates covering `[oldest, latest]` with concurrent `jrange` windows. +async fn fetch_jrange_windows( + data_access: &crate::api::DataAccess<'_, C>, + oldest: NaiveDate, + latest: NaiveDate, +) -> Result, String> { + let uid = data_access.uid.ok_or("No user ID available")?; + let token = data_access.token.ok_or("No token available for network request")?; + let windows = jrange_windows(oldest, latest); + if windows.is_empty() { + return Ok(vec![]); + } + + let concurrency = FETCH_CONCURRENCY.max(1); + let mut stream = futures::stream::iter(windows) + .map(|(ymd, range)| async move { fetch_jrange(data_access, uid, token, &ymd, range).await }) + .buffer_unordered(concurrency); + + let mut all_dates: Vec = Vec::new(); + while let Some(result) = stream.next().await { + all_dates.extend(result?); + } + + all_dates.sort(); + all_dates.dedup(); + let oldest_s = oldest.format("%Y-%m-%d").to_string(); + let latest_s = latest.format("%Y-%m-%d").to_string(); + Ok(filter_dates_by_range(all_dates, Some(&oldest_s), Some(&latest_s))) +} + pub async fn get_dates(data_access: &crate::api::DataAccess<'_, C>, latest: Option, oldest: Option, count: u32, reverse: bool) -> Result, String> { let uid = data_access.uid.ok_or("No user ID available")?; - let client = data_access.client; if !data_access.use_network { return get_dates_from_cache(uid, latest, oldest, count, reverse); } + // Bounded ranges can be covered by independent jrange windows in parallel. + if let (Some(latest_s), Some(oldest_s)) = (latest.as_deref(), oldest.as_deref()) + && let (Some(latest_d), Some(oldest_d)) = (parse_ymd(latest_s), parse_ymd(oldest_s)) + { + let dates = fetch_jrange_windows(data_access, oldest_d, latest_d).await?; + return Ok(limit_and_sort_dates(dates, count, reverse)); + } + let token = data_access.token.ok_or("No token available for network request")?; let initial_ymd = latest.clone().unwrap_or_else(|| { @@ -431,45 +553,17 @@ pub async fn get_dates(data_access: &crate::api::DataA format!("{:04}-{:02}-{:02}", today.year(), today.month(), today.day()) }); - let query = r#" -query GetJRange($uid: ID!, $ymd: YMD!, $range: Int!) { - jrange(uid: $uid, ymd: $ymd, range: $range) { - days { - on - } - } -} -"#; - let mut all_dates: Vec = Vec::new(); let mut current_ymd = initial_ymd.clone(); loop { - let want = (count as usize) - all_dates.len(); - let batch_size = std::cmp::min(32, want); - - let variables = serde_json::json!({ "uid": uid.to_string(), "ymd": current_ymd.clone(), "range": batch_size }); - - let response: models::GraphQLResponse = api::graphql_request(client, token, query, Some(variables)).await.map_err(|e| e.to_string())?; - - if let Some(errors) = response.errors { - return Err(errors.into_iter().map(|e| e.message).collect::>().join("; ")); + let want = (count as usize).saturating_sub(all_dates.len()); + if count > 0 && want == 0 { + break; } + let batch_size = std::cmp::min(JRANGE_MAX_WEEKS as usize, want.max(1)); - let days = if let Some(data) = response.data { - if let Some(jrange) = data.jrange { - jrange.days.unwrap_or_default() - } else { - vec![] - } - } else { - return Err("Unexpected response.".to_string()); - }; - - let mut date_strings: Vec = days.into_iter() - .filter_map(|day| day.on) - .map(|d| format!("{}-{}-{}", &d[0..4], &d[5..7], &d[8..10])) - .collect(); + let mut date_strings = fetch_jrange(data_access, uid, token, ¤t_ymd, batch_size as i32).await?; if date_strings.is_empty() { break; diff --git a/tests/test_date_utils.rs b/tests/test_date_utils.rs index 5416c42..f61525d 100644 --- a/tests/test_date_utils.rs +++ b/tests/test_date_utils.rs @@ -1,5 +1,6 @@ use chrono::NaiveDate; use wxrust::utils::{parse_date_boundary, parse_date_range}; +use wxrust::workouts::{jrange_windows, JRANGE_MAX_WEEKS}; #[test] fn test_parse_date_boundary_full_date() { @@ -172,4 +173,41 @@ fn test_parse_date_range_invalid() { // Too many parts assert!(parse_date_range("2025-05-01..2025-05-31..extra").is_err()); -} \ No newline at end of file +} + +#[test] +fn test_jrange_windows_same_day() { + let day = NaiveDate::from_ymd_opt(2023, 10, 1).unwrap(); + let windows = jrange_windows(day, day); + assert_eq!(windows, vec![("2023-10-01".to_string(), 1)]); +} + +#[test] +fn test_jrange_windows_empty_when_inverted() { + let oldest = NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(); + let latest = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + assert!(jrange_windows(oldest, latest).is_empty()); +} + +#[test] +fn test_jrange_windows_one_max_window() { + let latest = NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(); + let oldest = latest - chrono::Duration::days(JRANGE_MAX_WEEKS as i64 * 7); + let windows = jrange_windows(oldest, latest); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].0, "2026-09-04"); + assert_eq!(windows[0].1, JRANGE_MAX_WEEKS); +} + +#[test] +fn test_jrange_windows_year_needs_two() { + let oldest = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let latest = NaiveDate::from_ymd_opt(2026, 9, 4).unwrap(); + let windows = jrange_windows(oldest, latest); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0], ("2026-09-04".to_string(), JRANGE_MAX_WEEKS)); + assert!(windows[1].1 >= 1 && windows[1].1 <= JRANGE_MAX_WEEKS); + // Windows overlap by one day at the first window's start. + let first_start = latest - chrono::Duration::days(JRANGE_MAX_WEEKS as i64 * 7); + assert_eq!(windows[1].0, first_start.format("%Y-%m-%d").to_string()); +} diff --git a/tests/test_workouts.rs b/tests/test_workouts.rs index 2a5130e..052ff6e 100644 --- a/tests/test_workouts.rs +++ b/tests/test_workouts.rs @@ -280,6 +280,56 @@ async fn test_get_dates_success() { } } +#[tokio::test] +async fn test_get_dates_bounded_range() { + let _guard = ENV_MUTEX.lock().await; + let temp_dir = TempDir::new().unwrap(); + let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); + unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(1) + .returning(|_, _, _| { + Ok(GraphQLResponse { + data: Some(wxrust::models::GetJRangeData { + jrange: Some(wxrust::models::JRangeData { + days: Some(vec![wxrust::models::JRangeDayData { + on: Some("2023-10-01".to_string()), + }]), + }), + }), + errors: None, + }) + }); + + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: true, + write_cache: true, + }; + + let result = get_dates( + &data_access, + Some("2023-10-01".to_string()), + Some("2023-10-01".to_string()), + 1, + false, + ).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["2023-10-01".to_string()]); + + if let Ok(original) = original_xdg_cache { + unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + } else { + unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + } +} + #[tokio::test] async fn test_get_dates_invalid_token() { let _guard = ENV_MUTEX.lock().await;