From 56463255787eb2fdb865fb45af376d4bb4cb2667 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 09:17:03 +0000 Subject: [PATCH 1/2] fix(analytics): unify volume selector and correct weighted set filtering Agent-Logs-Url: https://github.com/gfauredev/LogOut/sessions/71104b9b-78e8-4caf-bac5-93182234d723 Co-authored-by: gfauredev <19304085+gfauredev@users.noreply.github.com> --- assets/en.ftl | 2 +- assets/es.ftl | 2 +- assets/fr.ftl | 2 +- src/components/analytics/mod.rs | 295 ++++++++++++--------------- src/components/analytics/selector.rs | 2 + 5 files changed, 139 insertions(+), 164 deletions(-) diff --git a/assets/en.ftl b/assets/en.ftl index 8261767..091d34b 100644 --- a/assets/en.ftl +++ b/assets/en.ftl @@ -159,12 +159,12 @@ analytics-mode-label = Analytics Mode analytics-mode-set = Set analytics-mode-session-avg = Session Avg analytics-mode-session-total = Session Total -analytics-volume-exercise-label = -- Volume Exercise -- analytics-pairs-label = Metric–Exercise Pairs (⩽ 8) analytics-empty = Select exercises to view analytics analytics-metric-weight = Weight (kg) analytics-metric-reps = Repetitions analytics-metric-distance = Distance analytics-metric-duration = Duration +analytics-metric-volume = Volume (kg·reps) analytics-select-exercise = -- Select Exercise -- analytics-remove-series = Remove this series diff --git a/assets/es.ftl b/assets/es.ftl index da3f175..a8da9b3 100644 --- a/assets/es.ftl +++ b/assets/es.ftl @@ -158,12 +158,12 @@ analytics-mode-label = Modo estadísticas analytics-mode-set = Serie analytics-mode-session-avg = Prom. sesión analytics-mode-session-total = Total sesión -analytics-volume-exercise-label = -- Ejercicio de volumen -- analytics-pairs-label = Pares métrica–ejercicio (⩽ 8) analytics-empty = Selecciona ejercicios para ver las estadísticas analytics-metric-weight = Peso (kg) analytics-metric-reps = Repeticiones analytics-metric-distance = Distancia analytics-metric-duration = Duración +analytics-metric-volume = Volumen (kg·rep) analytics-select-exercise = -- Seleccionar ejercicio -- analytics-remove-series = Eliminar esta serie diff --git a/assets/fr.ftl b/assets/fr.ftl index 9423b5c..dbd15f3 100644 --- a/assets/fr.ftl +++ b/assets/fr.ftl @@ -162,12 +162,12 @@ analytics-mode-label = Mode statistiques analytics-mode-set = Série analytics-mode-session-avg = Moy. séance analytics-mode-session-total = Total séance -analytics-volume-exercise-label = -- Exercice volume -- analytics-pairs-label = Paires métrique–exercice (⩽ 8) analytics-empty = Sélectionnez des exercices pour voir les statistiques analytics-metric-weight = Poids (kg) analytics-metric-reps = Répétitions analytics-metric-distance = Distance analytics-metric-duration = Durée +analytics-metric-volume = Volume (kg·rép) analytics-select-exercise = -- Sélectionner un exercice -- analytics-remove-series = Supprimer cette série diff --git a/src/components/analytics/mod.rs b/src/components/analytics/mod.rs index b9f6a9d..adb6bc2 100644 --- a/src/components/analytics/mod.rs +++ b/src/components/analytics/mod.rs @@ -12,12 +12,8 @@ mod selector; pub use chart::{ChartView, SeriesData}; pub use selector::MetricSelector; -/// Slot index reserved for the Volume series (beyond the 8 exercise pairs). -const VOLUME_SLOT: usize = 8; - -const COLORS: [&str; 9] = [ +const COLORS: [&str; 8] = [ "#3498db", "#e74c3c", "#2ecc71", "#9b59b6", "#e67e22", "#f1c40f", "#16a085", "#e91e63", - "#1abc9c", // volume series (chart 3) ]; #[component] @@ -25,7 +21,6 @@ pub fn Analytics() -> Element { let selected_pairs: Signal)>> = use_signal(|| vec![(Metric::Weight, None); 8]); let mut analytics_mode: Signal = use_signal(|| AnalyticsMode::Set); - let mut volume_exercise: Signal> = use_signal(|| None); let all_exercises = exercise_db::use_exercises(); let custom_exercises = storage::use_custom_exercises(); let lang_str = use_memo(move || i18n().language().to_string()); @@ -98,81 +93,153 @@ pub fn Analytics() -> Element { }); let mode = *analytics_mode.read(); - let vol_id = volume_exercise.read().clone(); + let selected_pairs_snapshot = selected_pairs.read().clone(); + let weighted_exercise_ids: std::collections::HashSet<&str> = selected_pairs_snapshot + .iter() + .filter_map(|(metric, opt_id)| (*metric == Metric::Weight).then_some(opt_id.as_deref())) + .flatten() + .collect(); let chart_data: SeriesData = { - let mut data: SeriesData = selected_pairs - .read() + selected_pairs_snapshot .iter() .enumerate() .filter_map(|(i, (metric, opt_id))| opt_id.as_ref().map(|id| (i, *metric, id.clone()))) .map(|(i, metric, exercise_id)| { let mut points = Vec::new(); - // Returns true if a log entry should contribute to this metric's series. - // Weight: weighted sets only. Reps/Distance/Duration: non-weighted sets. - let log_matches = |log: &crate::models::ExerciseLog| -> bool { - if log.exercise_id != exercise_id { - return false; - } - let w = log.weight_hg.0 > 0; - match metric { - Metric::Weight => w, - Metric::Reps | Metric::Distance | Metric::Duration => !w, - Metric::Volume => false, - } - }; - match mode { - AnalyticsMode::Set => { - for session in &sessions { - for log in &session.exercise_logs { - if log_matches(log) { - if let Some(value) = metric.extract_value(log) { - #[allow(clippy::cast_precision_loss)] - points.push((log.start_time as f64, value)); + if metric == Metric::Volume { + match mode { + AnalyticsMode::Set => { + for session in &sessions { + for log in &session.exercise_logs { + if log.exercise_id == exercise_id && log.weight_hg.0 > 0 { + if let Some(reps) = log.reps { + #[allow(clippy::cast_precision_loss)] + let vol = f64::from(log.weight_hg.0) / HG_PER_KG + * f64::from(reps); + #[allow(clippy::cast_precision_loss)] + points.push((log.start_time as f64, vol)); + } } } } } - } - AnalyticsMode::SessionAverage => { - for session in &sessions { - let values: Vec = session - .exercise_logs - .iter() - .filter(|log| log_matches(log)) - .filter_map(|log| metric.extract_value(log)) - .collect(); - if !values.is_empty() { - #[allow(clippy::cast_precision_loss)] - let avg = values.iter().sum::() / values.len() as f64; - #[allow(clippy::cast_precision_loss)] - points.push((session.start_time as f64, avg)); + AnalyticsMode::SessionAverage => { + for session in &sessions { + let vols: Vec = session + .exercise_logs + .iter() + .filter(|log| { + log.exercise_id == exercise_id && log.weight_hg.0 > 0 + }) + .filter_map(|log| { + log.reps.map(|r| { + f64::from(log.weight_hg.0) / HG_PER_KG * f64::from(r) + }) + }) + .collect(); + if !vols.is_empty() { + #[allow(clippy::cast_precision_loss)] + let avg = vols.iter().sum::() / vols.len() as f64; + #[allow(clippy::cast_precision_loss)] + points.push((session.start_time as f64, avg)); + } + } + } + AnalyticsMode::SessionTotal => { + for session in &sessions { + let total: f64 = session + .exercise_logs + .iter() + .filter(|log| { + log.exercise_id == exercise_id && log.weight_hg.0 > 0 + }) + .filter_map(|log| { + log.reps.map(|r| { + f64::from(log.weight_hg.0) / HG_PER_KG * f64::from(r) + }) + }) + .sum(); + if total > 0.0 { + #[allow(clippy::cast_precision_loss)] + points.push((session.start_time as f64, total)); + } } } } - AnalyticsMode::SessionTotal => { - for session in &sessions { - let values: Vec = session - .exercise_logs - .iter() - .filter(|log| log_matches(log)) - .filter_map(|log| metric.extract_value(log)) - .collect(); - if !values.is_empty() { - // Weight: show max per session (sum is not meaningful) - // All other metrics: show sum per session - #[allow(clippy::cast_precision_loss)] - let total = match metric { - Metric::Weight => { - values.iter().copied().fold(f64::NEG_INFINITY, f64::max) - } - Metric::Reps | Metric::Distance | Metric::Duration => { - values.iter().sum() + } else { + // Returns true if a log entry should contribute to this metric's series. + // Weight: weighted sets only. + // Reps/Distance/Duration: + // - weighted sets when the same exercise is selected in Weight + // - non-weighted sets otherwise. + let use_weighted_sets = weighted_exercise_ids.contains(exercise_id.as_str()); + let log_matches = |log: &crate::models::ExerciseLog| -> bool { + if log.exercise_id != exercise_id { + return false; + } + let is_weighted = log.weight_hg.0 > 0; + match metric { + Metric::Weight => is_weighted, + Metric::Reps | Metric::Distance | Metric::Duration => { + is_weighted == use_weighted_sets + } + Metric::Volume => false, + } + }; + match mode { + AnalyticsMode::Set => { + for session in &sessions { + for log in &session.exercise_logs { + if log_matches(log) { + if let Some(value) = metric.extract_value(log) { + #[allow(clippy::cast_precision_loss)] + points.push((log.start_time as f64, value)); + } } - Metric::Volume => 0.0, - }; - #[allow(clippy::cast_precision_loss)] - points.push((session.start_time as f64, total)); + } + } + } + AnalyticsMode::SessionAverage => { + for session in &sessions { + let values: Vec = session + .exercise_logs + .iter() + .filter(|log| log_matches(log)) + .filter_map(|log| metric.extract_value(log)) + .collect(); + if !values.is_empty() { + #[allow(clippy::cast_precision_loss)] + let avg = values.iter().sum::() / values.len() as f64; + #[allow(clippy::cast_precision_loss)] + points.push((session.start_time as f64, avg)); + } + } + } + AnalyticsMode::SessionTotal => { + for session in &sessions { + let values: Vec = session + .exercise_logs + .iter() + .filter(|log| log_matches(log)) + .filter_map(|log| metric.extract_value(log)) + .collect(); + if !values.is_empty() { + // Weight: show max per session (sum is not meaningful) + // All other metrics: show sum per session + #[allow(clippy::cast_precision_loss)] + let total = match metric { + Metric::Weight => { + values.iter().copied().fold(f64::NEG_INFINITY, f64::max) + } + Metric::Reps | Metric::Distance | Metric::Duration => { + values.iter().sum() + } + Metric::Volume => 0.0, + }; + #[allow(clippy::cast_precision_loss)] + points.push((session.start_time as f64, total)); + } } } } @@ -186,79 +253,9 @@ pub fn Analytics() -> Element { .map_or_else(|| exercise_id.clone(), |(_, name)| name.clone()); (i, exercise_name, metric, points) }) - .collect(); - - // Volume series: chart 3, aggregated according to the global mode - if let Some(exercise_id) = vol_id { - let mut points = Vec::new(); - match mode { - AnalyticsMode::Set => { - for session in &sessions { - for log in &session.exercise_logs { - if log.exercise_id == exercise_id && log.weight_hg.0 > 0 { - if let Some(reps) = log.reps { - #[allow(clippy::cast_precision_loss)] - let vol = - f64::from(log.weight_hg.0) / HG_PER_KG * f64::from(reps); - #[allow(clippy::cast_precision_loss)] - points.push((log.start_time as f64, vol)); - } - } - } - } - } - AnalyticsMode::SessionAverage => { - for session in &sessions { - let vols: Vec = session - .exercise_logs - .iter() - .filter(|log| log.exercise_id == exercise_id && log.weight_hg.0 > 0) - .filter_map(|log| { - log.reps - .map(|r| f64::from(log.weight_hg.0) / HG_PER_KG * f64::from(r)) - }) - .collect(); - if !vols.is_empty() { - #[allow(clippy::cast_precision_loss)] - let avg = vols.iter().sum::() / vols.len() as f64; - #[allow(clippy::cast_precision_loss)] - points.push((session.start_time as f64, avg)); - } - } - } - AnalyticsMode::SessionTotal => { - for session in &sessions { - let total: f64 = session - .exercise_logs - .iter() - .filter(|log| log.exercise_id == exercise_id && log.weight_hg.0 > 0) - .filter_map(|log| { - log.reps - .map(|r| f64::from(log.weight_hg.0) / HG_PER_KG * f64::from(r)) - }) - .sum(); - if total > 0.0 { - #[allow(clippy::cast_precision_loss)] - points.push((session.start_time as f64, total)); - } - } - } - } - points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); - let exercise_name = available_by_metric - .read() - .get(Metric::Volume.to_index()) - .and_then(|list| list.iter().find(|(id, _)| *id == exercise_id)) - .map_or_else(|| exercise_id.clone(), |(_, name)| name.clone()); - data.push((VOLUME_SLOT, exercise_name, Metric::Volume, points)); - } - data + .collect() }; - let volume_exercises: Vec<(String, String)> = - available_by_metric.read()[Metric::Volume.to_index()].clone(); - let vol_is_locked = volume_exercise.read().is_some(); - rsx! { header { h1 { {t!("analytics-title")} } @@ -295,30 +292,6 @@ pub fn Analytics() -> Element { } } } - div { class: "exercise-selector", - div { style: "background: {COLORS[VOLUME_SLOT]};" } - select { - value: "{volume_exercise.read().as_deref().unwrap_or(\"\")}", - disabled: vol_is_locked, - onchange: move |evt| { - let value = evt.value(); - volume_exercise.set(if value.is_empty() { None } else { Some(value) }); - }, - option { value: "", {t!("analytics-volume-exercise-label")} } - for (id, name) in volume_exercises.iter() { - option { value: "{id}", "{name}" } - } - } - if vol_is_locked { - button { - class: "back", - r#type: "button", - title: t!("analytics-remove-series"), - onclick: move |_| volume_exercise.set(None), - "✕" - } - } - } label { {t!("analytics-pairs-label")} } for i in 0..8 { MetricSelector { diff --git a/src/components/analytics/selector.rs b/src/components/analytics/selector.rs index ab9859c..d0cf289 100644 --- a/src/components/analytics/selector.rs +++ b/src/components/analytics/selector.rs @@ -40,6 +40,7 @@ pub fn MetricSelector( "Reps" => Metric::Reps, "Distance" => Metric::Distance, "Duration" => Metric::Duration, + "Volume" => Metric::Volume, _ => Metric::Weight, }; pairs[i].1 = None; @@ -48,6 +49,7 @@ pub fn MetricSelector( option { value: "Reps", {t!("analytics-metric-reps")} } option { value: "Distance", {t!("analytics-metric-distance")} } option { value: "Duration", {t!("analytics-metric-duration")} } + option { value: "Volume", {t!("analytics-metric-volume")} } } select { value: "{current_exercise.as_deref().unwrap_or(\"\")}", From ca2813d6102ee3fd037714ae3787151ec8f0e0c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 09:25:07 +0000 Subject: [PATCH 2/2] refactor(analytics): remove unreachable volume fallback in totals Agent-Logs-Url: https://github.com/gfauredev/LogOut/sessions/71104b9b-78e8-4caf-bac5-93182234d723 Co-authored-by: gfauredev <19304085+gfauredev@users.noreply.github.com> --- src/components/analytics/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/analytics/mod.rs b/src/components/analytics/mod.rs index adb6bc2..7409fd7 100644 --- a/src/components/analytics/mod.rs +++ b/src/components/analytics/mod.rs @@ -12,6 +12,7 @@ mod selector; pub use chart::{ChartView, SeriesData}; pub use selector::MetricSelector; +/// One color per metric–exercise pair selector slot (max 8 visible series). const COLORS: [&str; 8] = [ "#3498db", "#e74c3c", "#2ecc71", "#9b59b6", "#e67e22", "#f1c40f", "#16a085", "#e91e63", ]; @@ -235,7 +236,9 @@ pub fn Analytics() -> Element { Metric::Reps | Metric::Distance | Metric::Duration => { values.iter().sum() } - Metric::Volume => 0.0, + Metric::Volume => unreachable!( + "volume is handled by the dedicated branch above" + ), }; #[allow(clippy::cast_precision_loss)] points.push((session.start_time as f64, total));