Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
67 changes: 13 additions & 54 deletions src/heatmap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,7 @@ pub fn compute_metric(jday: &JDay, metric: Metric, filters: &[String]) -> f64 {
}

/// Handle the heatmap command
pub async fn handle_heatmap<C: ApiClient + Clone + Send + Sync + 'static>(
client: &C,
token: &Option<String>,
pub async fn handle_heatmap<C: ApiClient>(
data_access: DataAccess<'_, C>,
metric: Metric,
green: bool,
Expand Down Expand Up @@ -136,61 +134,22 @@ pub async fn handle_heatmap<C: ApiClient + Clone + Send + Sync + 'static>(
}
}

// 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<NaiveDate, f64> = 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() {
Expand Down
161 changes: 37 additions & 124 deletions src/list.rs
Original file line number Diff line number Diff line change
@@ -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<C: ApiClient>(
list: &crate::ListArgs,
client: &ReqwestClient,
token: &Option<String>,
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);
Expand Down Expand Up @@ -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<usize, Option<String>> = 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<usize, (String, Option<models::JDay>)> = 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);
}
}
Expand Down
13 changes: 4 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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))
};

Expand Down Expand Up @@ -321,7 +318,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

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;
Expand All @@ -330,7 +327,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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
Expand All @@ -349,8 +346,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
};

heatmap::handle_heatmap(
&client,
&token,
data_access,
metric,
heatmap_args.green,
Expand Down
Loading
Loading