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)

This commit is contained in:
Peter Stockings
2023-12-11 17:29:10 +11:00
parent 042d895161
commit 2285e870fb
5 changed files with 242 additions and 9 deletions

13
app.py
View File

@@ -7,11 +7,10 @@ import jinja_partials
from jinja2_fragments import render_block from jinja2_fragments import render_block
from decorators import validate_person, validate_topset, validate_workout from decorators import validate_person, validate_topset, validate_workout
from db import DataBase 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 from flask_htmx import HTMX
import minify_html import minify_html
from urllib.parse import urlparse, unquote, quote from urllib.parse import quote
import random
app = Flask(__name__) app = Flask(__name__)
app.config.from_pyfile('config.py') app.config.from_pyfile('config.py')
@@ -61,9 +60,13 @@ def dashboard():
people_and_exercise_rep_maxes = get_people_and_exercise_rep_maxes( people_and_exercise_rep_maxes = get_people_and_exercise_rep_maxes(
all_topsets, selected_person_ids, selected_exercise_ids, min_date, max_date) 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: 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_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) 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']) @ app.route("/person/list", methods=['GET'])

View File

@@ -8,4 +8,5 @@ python-dateutil==2.8.2
minify-html==0.10.3 minify-html==0.10.3
jinja2-fragments==0.3.0 jinja2-fragments==0.3.0
Werkzeug==2.2.2 Werkzeug==2.2.2
numpy==1.19.5 numpy==1.19.5
pandas==1.3.1

View File

@@ -2,6 +2,14 @@
{% block content %} {% block content %}
<div class="w-full mb-4 grid grid-cols-1 xl:grid-cols-2 gap-4">
{% for graph in dashboard_graphs %}
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8">
{{ render_partial('partials/svg_line_graph.html', **graph) }}
</div>
{% endfor %}
</div>
<div class="bg-white shadow rounded-lg pt-4 p-3 md:p-4 w-full mb-4"> <div class="bg-white shadow rounded-lg pt-4 p-3 md:p-4 w-full mb-4">
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<div class="w-full lg:w-1/4 sm:w-full px-3 mb-6 md:mb-0"> <div class="w-full lg:w-1/4 sm:w-full px-3 mb-6 md:mb-0">

View File

@@ -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 %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1" fill="{{ color }}"></circle>
{% endif %}
{% endfor %}
{% endmacro %}
{% macro plot_line(points, color) %}
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
{{ circles(points, color) }}
{% endmacro %}
<!-- HubSpot doesn't escape whitespace. -->
{% 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 %}
<!-- You have to manually call the macros in a list. -->
{% set parts = [random_int()] %}
{% set unique_id = parts|join('-') %}
<div class="relative" id="svg-plot-{{ unique_id }}" _="
on mouseover from .pnt-{{ unique_id }}
put event.target @data-msg into #popover-{{ unique_id }}
then remove .hidden from #popover-{{ unique_id }}
on mouseout from .pnt-{{ unique_id }}
add .hidden to #popover-{{ unique_id }}">
<div id="popover-{{ unique_id }}" class="absolute t-0 r-0 hidden bg-white border border-gray-300 p-2 z-10">
<!-- Popover content will be dynamically inserted here -->
</div>
<h4 class="text-l font-semibold text-blue-400 mb-2 text-center">{{ title }}</h4>
<svg viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}"
preserveAspectRatio="none">
{% for plot in plots %}
<g class="{{ plot.label }}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
{{ plot_line(plot.points, plot.color) }}
</g>
{% endfor %}
<g style="fill-opacity: 0%">
{% 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 %}
<rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}"
class="pnt-{{ unique_id }}" data-msg="{{ message }}"></rect>
{% endfor %}
</g>
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none"
stroke-opacity="60%" />
</svg>
<div class="flex justify-center pt-2">
{% for plot in plots %}
<div class="flex items-center px-2 select-none cursor-pointer" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
on click toggle .hidden on my.plot_line then toggle .line-through on me">
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
<div class="text-xs">{{ plot.label }}</div>
</div>
{% endfor %}
</div>
</div>

139
utils.py
View File

@@ -1,7 +1,8 @@
import colorsys
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import random
import numpy as np import numpy as np
import json import pandas as pd
def get_workouts(topsets): def get_workouts(topsets):
# Get all unique workout_ids (No duplicates) # 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], 'plots': [repetitions, weight, estimated_1rm],
'best_fit_points': best_fit_points, 'best_fit_points': best_fit_points,
'plot_labels': plot_labels 'plot_labels': plot_labels
} }
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