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 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'])

View File

@@ -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
numpy==1.19.5
pandas==1.3.1

View File

@@ -2,6 +2,14 @@
{% 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="flex flex-wrap">
<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
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
}
}
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