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:
13
app.py
13
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'])
|
||||
|
||||
@@ -9,3 +9,4 @@ minify-html==0.10.3
|
||||
jinja2-fragments==0.3.0
|
||||
Werkzeug==2.2.2
|
||||
numpy==1.19.5
|
||||
pandas==1.3.1
|
||||
@@ -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">
|
||||
|
||||
88
templates/partials/svg_line_graph.html
Normal file
88
templates/partials/svg_line_graph.html
Normal 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>
|
||||
137
utils.py
137
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)
|
||||
@@ -306,3 +307,135 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
|
||||
'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
|
||||
Reference in New Issue
Block a user