From 2285e870fbce3d142d25e731b181aba35f794173 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 11 Dec 2023 17:29:10 +1100 Subject: [PATCH] Added graphs to show workouts & PR's per week on dashboard. However there is no tooltip on hover and I duplicated the svg spark line template (May combine the two) --- app.py | 13 ++- requirements.txt | 3 +- templates/dashboard.html | 8 ++ templates/partials/svg_line_graph.html | 88 ++++++++++++++++ utils.py | 139 ++++++++++++++++++++++++- 5 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 templates/partials/svg_line_graph.html diff --git a/app.py b/app.py index 71d23f9..5a41021 100644 --- a/app.py +++ b/app.py @@ -7,11 +7,10 @@ import jinja_partials from jinja2_fragments import render_block from decorators import validate_person, validate_topset, validate_workout from db import DataBase -from utils import flatten, get_date_info, get_people_and_exercise_rep_maxes, convert_str_to_date, get_earliest_and_latest_workout_date, filter_workout_topsets, get_exercise_ids_from_workouts, first_and_last_visible_days_in_month +from utils import count_prs_over_time, get_date_info, get_people_and_exercise_rep_maxes, convert_str_to_date, get_earliest_and_latest_workout_date, filter_workout_topsets, first_and_last_visible_days_in_month, get_weekly_pr_graph_model, get_workout_counts from flask_htmx import HTMX import minify_html -from urllib.parse import urlparse, unquote, quote -import random +from urllib.parse import quote app = Flask(__name__) app.config.from_pyfile('config.py') @@ -61,9 +60,13 @@ def dashboard(): people_and_exercise_rep_maxes = get_people_and_exercise_rep_maxes( all_topsets, selected_person_ids, selected_exercise_ids, min_date, max_date) + weekly_counts = get_workout_counts(all_topsets, 'week') + weekly_pr_counts = count_prs_over_time(all_topsets, 'week') + dashboard_graphs = [get_weekly_pr_graph_model('Workouts per week', weekly_counts), get_weekly_pr_graph_model('PRs per week', weekly_pr_counts)] + if htmx: - return render_block(app.jinja_env, 'dashboard.html', 'content', model=people_and_exercise_rep_maxes, people=people, exercises=exercises, min_date=min_date, max_date=max_date, selected_person_ids=selected_person_ids, selected_exercise_ids=selected_exercise_ids, tags=tags) - return render_template('dashboard.html', model=people_and_exercise_rep_maxes, people=people, exercises=exercises, min_date=min_date, max_date=max_date, selected_person_ids=selected_person_ids, selected_exercise_ids=selected_exercise_ids, tags=tags) + return render_block(app.jinja_env, 'dashboard.html', 'content', model=people_and_exercise_rep_maxes, people=people, exercises=exercises, min_date=min_date, max_date=max_date, selected_person_ids=selected_person_ids, selected_exercise_ids=selected_exercise_ids, tags=tags, dashboard_graphs=dashboard_graphs) + return render_template('dashboard.html', model=people_and_exercise_rep_maxes, people=people, exercises=exercises, min_date=min_date, max_date=max_date, selected_person_ids=selected_person_ids, selected_exercise_ids=selected_exercise_ids, tags=tags, dashboard_graphs=dashboard_graphs) @ app.route("/person/list", methods=['GET']) diff --git a/requirements.txt b/requirements.txt index 808f5ae..2b67ad7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ python-dateutil==2.8.2 minify-html==0.10.3 jinja2-fragments==0.3.0 Werkzeug==2.2.2 -numpy==1.19.5 \ No newline at end of file +numpy==1.19.5 +pandas==1.3.1 \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index 0c2f0b0..24d61bb 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -2,6 +2,14 @@ {% block content %} +
+ {% for graph in dashboard_graphs %} +
+ {{ render_partial('partials/svg_line_graph.html', **graph) }} +
+ {% endfor %} +
+
diff --git a/templates/partials/svg_line_graph.html b/templates/partials/svg_line_graph.html new file mode 100644 index 0000000..23d5a0b --- /dev/null +++ b/templates/partials/svg_line_graph.html @@ -0,0 +1,88 @@ +{% set stroke_width = 4 %} +{% set margin = 2 %} + +{% macro path(data_points, vb_height) %} +{% for value, position in data_points %} +{% set x = (position * vb_width)+margin %} +{% set y = (vb_height - value)+margin %} +{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %} +{% endfor %} +{% endmacro %} + +{% macro path_best_fit(best_fit_points, vb_height) %} +{% for value, position in best_fit_points %} +{% set x = (position * vb_width)+margin %} +{% set y = (vb_height - value)+margin %} +{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %} +{% endfor %} +{% endmacro %} + +{% macro circles(data_points, color) %} + {% for i in range(data_points|length) %} + {% set current_value, current_position = data_points[i] %} + {% set prev_value = data_points[i - 1][0] if i > 0 else None %} + {% set next_value = data_points[i + 1][0] if i < data_points|length - 1 else None %} + {# Plot the circle only if the current value is different from both previous and next values #} + {% if next_value != prev_value or (next_value == prev_value and next_value != current_value) %} + {% set x=(current_position * vb_width) + margin %} + {% set y=(vb_height - current_value) + margin %} + + {% endif %} + {% endfor %} +{% endmacro %} + + {% macro plot_line(points, color) %} + + {{ circles(points, color) }} + {% endmacro %} + + + {% macro random_int() %}{% for n in [0,1,2,3,4,5] %}{{ [0,1,2,3,4,5,6,7,8,9]|random }}{% endfor %}{% endmacro %} + + + {% set parts = [random_int()] %} + {% set unique_id = parts|join('-') %} + +
+ +

{{ title }}

+ + {% for plot in plots %} + + {{ plot_line(plot.points, plot.color) }} + + {% endfor %} + + + {% for pos, message in plot_labels %} + {% set x = (pos * vb_width) - (stroke_width/2) + margin %} + {% set y = 0 %} + {% set width = stroke_width %} + {% set height = vb_height + margin %} + + {% endfor %} + + + + +
+ {% for plot in plots %} +
+
+
{{ plot.label }}
+
+ {% endfor %} +
+ +
\ No newline at end of file diff --git a/utils.py b/utils.py index 7f59d3f..88b43b0 100644 --- a/utils.py +++ b/utils.py @@ -1,7 +1,8 @@ +import colorsys from datetime import datetime, date, timedelta +import random import numpy as np -import json - +import pandas as pd def get_workouts(topsets): # Get all unique workout_ids (No duplicates) @@ -305,4 +306,136 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da 'plots': [repetitions, weight, estimated_1rm], 'best_fit_points': best_fit_points, 'plot_labels': plot_labels - } \ No newline at end of file + } + +def get_workout_counts(workouts, period='week'): + # Convert to DataFrame + df = pd.DataFrame(workouts) + + # Convert 'StartDate' to datetime + df['StartDate'] = pd.to_datetime(df['StartDate']) + + # Determine the range of periods to cover + min_date = df['StartDate'].min() + max_date = pd.Timestamp(datetime.now()) + + # Generate a complete range of periods + freq = 'W-MON' if period == 'week' else 'MS' + period_range = pd.date_range(start=min_date, end=max_date, freq=freq) + + # Initialize a dictionary to store workout counts and person names + workout_counts = { + person_id: { + "PersonName": person_name, + "PRCounts": {p: 0 for p in period_range} + } for person_id, person_name in df[['PersonId', 'PersonName']].drop_duplicates().values + } + + # Process the workouts + for person_id, person_data in workout_counts.items(): + person_df = df[df['PersonId'] == person_id] + + for period_start in person_data["PRCounts"]: + period_end = period_start + pd.DateOffset(weeks=1) if period == 'week' else period_start + pd.DateOffset(months=1) + period_workouts = person_df[(person_df['StartDate'] >= period_start) & (person_df['StartDate'] < period_end)] + person_data["PRCounts"][period_start] = len(period_workouts) + + return workout_counts + +def count_prs_over_time(workouts, period='week'): + # Convert to DataFrame + df = pd.DataFrame(workouts) + + # Convert 'StartDate' to datetime + df['StartDate'] = pd.to_datetime(df['StartDate']) + + # Determine the range of periods to cover + min_date = df['StartDate'].min() + max_date = pd.Timestamp(datetime.now()) + + # Generate a complete range of periods + period_range = pd.date_range(start=min_date, end=max_date, freq='W-MON' if period == 'week' else 'MS') + + # Initialize a dictionary to store PR counts and names + pr_counts = { + person_id: { + "PersonName": person_name, + "PRCounts": {p: 0 for p in period_range} + } for person_id, person_name in df[['PersonId', 'PersonName']].drop_duplicates().values + } + + # Process the workouts + for person_id, person_data in pr_counts.items(): + person_df = df[df['PersonId'] == person_id] + + for period_start in person_data["PRCounts"]: + period_end = period_start + pd.DateOffset(weeks=1) if period == 'week' else period_start + pd.DateOffset(months=1) + period_workouts = person_df[(person_df['StartDate'] >= period_start) & (person_df['StartDate'] < period_end)] + + for exercise_id in period_workouts['ExerciseId'].unique(): + exercise_max = period_workouts[period_workouts['ExerciseId'] == exercise_id]['Estimated1RM'].max() + + # Check if this is a PR + previous_max = person_df[(person_df['StartDate'] < period_start) & + (person_df['ExerciseId'] == exercise_id)]['Estimated1RM'].max() + + if pd.isna(previous_max) or exercise_max > previous_max: + person_data["PRCounts"][period_start] += 1 + + return pr_counts + + +def get_weekly_pr_graph_model(title, weekly_pr_data): + # Assuming weekly_pr_data is in the format {1: {"PersonName": "Alice", "PRCounts": {Timestamp('2022-01-01', freq='W-MON'): 0, ...}}, 2: {...}, ...} + + # Find the overall date range for all users + all_dates = [date for user_data in weekly_pr_data.values() for date in user_data["PRCounts"].keys()] + min_date, max_date = min(all_dates), max(all_dates) + total_span = (max_date - min_date).days or 1 + relative_positions = [(date - min_date).days / total_span for date in all_dates] + + # Calculate viewBox dimensions + max_pr_count = max(max(user_data["PRCounts"].values()) for user_data in weekly_pr_data.values()) or 1 + vb_width, vb_height = total_span, max_pr_count + vb_width *= 200 / vb_width # Scale to 200px width + vb_height *= 75 / vb_height # Scale to 75px height + + plots = [] + colors = get_distinct_colors(len(weekly_pr_data.items())) + for count, (user_id, user_data) in enumerate(weekly_pr_data.items()): + pr_counts = user_data["PRCounts"] + person_name = user_data["PersonName"] + + values = pr_counts.values() + min_value, max_value = min(values), max(values) + value_range = (max_value - min_value) or 1 + + values_scaled = [((value - min_value) / value_range) * vb_height for value in values] + plot_points = list(zip(values_scaled, relative_positions)) + + # Create a plot for each user + plot = { + 'label': person_name, # Use PersonName instead of User ID + 'color': colors[count], + 'points': plot_points + } + plots.append(plot) + + # Return workout data with SVG dimensions and data points + return { + 'title': title, + 'vb_width': vb_width, + 'vb_height': vb_height, + 'plots': plots + } + +def get_distinct_colors(n): + colors = [] + for i in range(n): + # Divide the color wheel into n parts + hue = i / n + # Convert HSL (Hue, Saturation, Lightness) to RGB and then to a Hex string + rgb = colorsys.hls_to_rgb(hue, 0.6, 0.4) # Fixed lightness and saturation + hex_color = '#{:02x}{:02x}{:02x}'.format(int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255)) + colors.append(hex_color) + return colors \ No newline at end of file