From f70438e4e49668f17c04e2db1272881c5baecc07 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 27 Jan 2025 14:46:20 +1100 Subject: [PATCH] Refactor dashboard --- app.py | 64 ++++++---- db.py | 35 ++--- features/dashboard.py | 270 +++++++++++++++++++++++++++++++++++++++ templates/dashboard.html | 42 +++--- utils.py | 142 -------------------- 5 files changed, 335 insertions(+), 218 deletions(-) create mode 100644 features/dashboard.py diff --git a/app.py b/app.py index 7339a63..899b504 100644 --- a/app.py +++ b/app.py @@ -6,7 +6,7 @@ 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 get_people_and_exercise_rep_maxes, convert_str_to_date, first_and_last_visible_days_in_month, generate_plot +from utils import convert_str_to_date, generate_plot from flask_htmx import HTMX import minify_html from urllib.parse import quote @@ -38,36 +38,44 @@ def response_minify(response): return response return response - @ app.route("/") def dashboard(): - all_topsets = db.get_all_topsets() + selected_people_ids = request.args.getlist('person_id', type=int) + min_date = request.args.get('min_date', type=convert_str_to_date) + max_date = request.args.get('max_date', type=convert_str_to_date) + selected_exercise_ids = request.args.getlist('exercise_id', type=int) - exercises = db.get_all_exercises() - people = db.get_people() - tags = db.get_tags_for_dashboard() + if not selected_people_ids and htmx.trigger_name != 'person_id': + selected_people_ids = db.dashboard.get_people_ids() - selected_person_ids = [int(i) - for i in request.args.getlist('person_id')] - if not selected_person_ids and htmx.trigger_name != 'person_id': - selected_person_ids = [p['PersonId'] for p in people] - - selected_exercise_ids = [int(i) - for i in request.args.getlist('exercise_id')] + if not min_date or not max_date: + db_min_date, db_max_date = db.dashboard.get_earliest_and_latest_workout_dates(selected_people_ids) + min_date = min_date or db_min_date + max_date = max_date or db_max_date + if not selected_exercise_ids and htmx.trigger_name != 'exercise_id': - selected_exercise_ids = [e['exercise_id'] for e in exercises] + selected_exercise_ids = db.dashboard.list_of_performed_exercise_ids(selected_people_ids, min_date, max_date) + + people = db.dashboard.get_people_with_selection(selected_people_ids) + exercises = db.dashboard.get_exercises_with_selection(selected_people_ids, min_date, max_date, selected_exercise_ids) + tags = db.get_tags_for_dashboard() + dashboard = db.dashboard.get(selected_people_ids, min_date, max_date, selected_exercise_ids) - min_date = convert_str_to_date(request.args.get( - 'min_date'), '%Y-%m-%d') or min([t['StartDate'] for t in all_topsets]) - max_date = convert_str_to_date(request.args.get( - 'max_date'), '%Y-%m-%d') or max([t['StartDate'] for t in all_topsets]) - - people_and_exercise_rep_maxes = get_people_and_exercise_rep_maxes( - all_topsets, selected_person_ids, selected_exercise_ids, min_date, max_date) + # Render the appropriate response for HTMX or full page + render_args = { + **dashboard, + "people": people, + "exercises": exercises, + "tags": tags, + "selected_people_ids": selected_people_ids, + "max_date": max_date, + "min_date": min_date, + "selected_exercise_ids": selected_exercise_ids + } 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', **render_args) + return render_template('dashboard.html', **render_args) @ app.route("/person/list", methods=['GET']) @@ -238,8 +246,8 @@ def delete_person(person_id): @ app.route("/person//edit_form", methods=['GET']) def get_person_edit_form(person_id): - person = db.get_person(person_id) - return render_template('partials/person.html', person_id=person_id, name=person['PersonName'], is_edit=True) + name = db.get_person_name(person_id) + return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True) @ app.route("/person//name", methods=['PUT']) @@ -251,8 +259,8 @@ def update_person_name(person_id): @ app.route("/person//name", methods=['GET']) def get_person_name(person_id): - person = db.get_person(person_id) - return render_template('partials/person.html', person_id=person_id, name=person['PersonName']) + name = db.get_person_name(person_id) + return render_template('partials/person.html', person_id=person_id, name=name) @ app.route("/exercise", methods=['POST']) @@ -634,7 +642,7 @@ def my_utility_processor(): def list_to_string(list): return [str(i) for i in list] - return dict(is_selected_page=is_selected_page, get_first_element_from_list_with_matching_attribute=get_first_element_from_list_with_matching_attribute, in_list=in_list, strftime=strftime, datetime=datetime, timedelta=timedelta, relativedelta=relativedelta, first_and_last_visible_days_in_month=first_and_last_visible_days_in_month, list_to_string=list_to_string, quote=quote) + return dict(is_selected_page=is_selected_page, get_first_element_from_list_with_matching_attribute=get_first_element_from_list_with_matching_attribute, in_list=in_list, strftime=strftime, datetime=datetime, timedelta=timedelta, relativedelta=relativedelta, list_to_string=list_to_string, quote=quote) if __name__ == '__main__': diff --git a/db.py b/db.py index f841a4a..b179d1b 100644 --- a/db.py +++ b/db.py @@ -13,7 +13,8 @@ from features.person_overview import PersonOverview from features.stats import Stats from features.workout import Workout from features.sql_explorer import SQLExplorer -from utils import get_all_exercises_from_topsets, get_exercise_graph_model, get_topsets_for_person, get_workouts +from features.dashboard import Dashboard +from utils import get_exercise_graph_model class DataBase(): @@ -25,6 +26,8 @@ class DataBase(): self.sql_explorer = SQLExplorer(self.execute) self.person_overview = PersonOverview(self.execute) self.people_graphs = PeopleGraphs(self.execute) + self.dashboard = Dashboard(self.execute) + db_url = urlparse(os.environ['DATABASE_URL']) # if db_url is null then throw error if not db_url: @@ -188,33 +191,11 @@ class DataBase(): def update_workout_start_date(self, workout_id, start_date): self.execute('UPDATE workout SET start_date=%s WHERE workout_id=%s', [ start_date, workout_id], commit=True) + + def get_person_name(self, person_id): + result = self.execute("""SELECT name from Person WHERE person_id=%s""", [person_id], one=True) + return result["name"] - def get_person(self, person_id): - topsets = self.execute(""" - SELECT - P.person_id AS "PersonId", - P.name AS "PersonName", - W.workout_id AS "WorkoutId", - W.start_date AS "StartDate", - T.topset_id AS "TopSetId", - E.exercise_id AS "ExerciseId", - E.name AS "ExerciseName", - T.repetitions AS "Repetitions", - T.weight AS "Weight", - round((100 * T.Weight::numeric::integer)/(101.3-2.67123 * T.Repetitions),0)::numeric::integer AS "Estimated1RM" - FROM Person P - LEFT JOIN Workout W ON P.person_id=W.person_id - LEFT JOIN TopSet T ON W.workout_id=T.workout_id - LEFT JOIN Exercise E ON T.exercise_id=E.exercise_id - WHERE P.person_id=%s""", [person_id]) - - return { - 'PersonId': next((t['PersonId'] for t in topsets), -1), - 'PersonName': next((t['PersonName'] for t in topsets), 'Unknown'), - 'Exercises': get_all_exercises_from_topsets(topsets), - 'Workouts': get_workouts(topsets), - 'ExerciseProgressGraphs': get_topsets_for_person(topsets) - } def get_workout(self, person_id, workout_id): topsets = self.execute(""" diff --git a/features/dashboard.py b/features/dashboard.py new file mode 100644 index 0000000..c869f92 --- /dev/null +++ b/features/dashboard.py @@ -0,0 +1,270 @@ +from utils import calculate_estimated_1rm, get_exercise_graph_model + + +class Dashboard: + def __init__(self, db_connection_method): + self.execute = db_connection_method + + def get_people_ids(self): + query = """ + SELECT person_id + FROM Person + ORDER BY person_id + """ + result = self.execute(query) + # Extract and return the list of IDs + return [row["person_id"] for row in result] + + def get_earliest_and_latest_workout_dates(self, selected_people_ids): + # Create placeholders for the person IDs + placeholders = ", ".join(["%s"] * len(selected_people_ids)) + + sql_query = f""" + SELECT + MIN(w.start_date) AS earliest_date, + MAX(w.start_date) AS latest_date + FROM workout w + INNER JOIN topset t ON w.workout_id = t.workout_id + WHERE w.person_id IN ({placeholders}); + """ + result = self.execute(sql_query, selected_people_ids) + + if not result or not result[0]: + return None, None + + return result[0]['earliest_date'], result[0]['latest_date'] + + def list_of_performed_exercise_ids(self, selected_people_ids, min_date, max_date): + # Create placeholders for the person IDs + placeholders = ", ".join(["%s"] * len(selected_people_ids)) + + sql_query = f""" + SELECT + ARRAY_AGG(DISTINCT e.exercise_id) AS exercise_ids + FROM workout w + LEFT JOIN topset t ON w.workout_id = t.workout_id + LEFT JOIN exercise e ON t.exercise_id = e.exercise_id + WHERE w.start_date BETWEEN %s AND %s + AND w.person_id IN ({placeholders}) + """ + # Add min_date, max_date, and selected_people_ids to the parameters + params = [min_date, max_date] + selected_people_ids + + result = self.execute(sql_query, params) + + if not result or not result[0]: + return [] + + return result[0]['exercise_ids'] + + def get_exercises_with_selection(self, selected_people_ids, start_date, end_date, selected_exercise_ids): + # Create placeholders for the person IDs + placeholders = ", ".join(["%s"] * len(selected_people_ids)) + + # SQL query to fetch all exercises performed by the selected people in the given time range + sql_query = f""" + SELECT DISTINCT + e.exercise_id, + e.name AS exercise_name + FROM + workout w + JOIN + topset t ON w.workout_id = t.workout_id + JOIN + exercise e ON t.exercise_id = e.exercise_id + WHERE + w.person_id IN ({placeholders}) + AND w.start_date BETWEEN %s AND %s + ORDER BY + e.name ASC; + """ + + # Add parameters for the query + params = selected_people_ids + [start_date, end_date] + + # Execute the query with parameters + result = self.execute(sql_query, params) + + if not result: + return [] # No exercises found in the given time range + + # Add the "selected" property to each exercise + exercises = [] + for row in result: + exercises.append({ + "id": row["exercise_id"], + "name": row["exercise_name"], + "selected": row["exercise_id"] in selected_exercise_ids + }) + + return exercises + + def get_people_with_selection(self, selected_people_ids): + # SQL query to fetch all people + sql_query = """ + SELECT DISTINCT + p.person_id AS id, + p.name AS name + FROM + person p + ORDER BY + p.name ASC; + """ + + # Execute the query (no parameters required since we're fetching all people) + result = self.execute(sql_query) + + if not result: + return [] # No people found + + # Add the "selected" property to each person + people = [] + for row in result: + people.append({ + "id": row["id"], + "name": row["name"], + "selected": row["id"] in selected_people_ids + }) + + return people + + def generate_exercise_progress_graphs(self, person_id, exercise_id, exercise_name, exercise_sets): + # Extract the required data + estimated_1rm = [t["estimated_1rm"] for t in exercise_sets] + repetitions = [t["reps"] for t in exercise_sets] + weight = [t["weight"] for t in exercise_sets] + start_dates = [t["workout_start_date"] for t in exercise_sets] + messages = [ + f'{t["reps"]} x {t["weight"]}kg ({t["estimated_1rm"]}kg E1RM) on {t["workout_start_date"].strftime("%d %b %y")}' + for t in exercise_sets + ] + epoch = "All" + + # Check for valid data before generating the graph + if exercise_name and estimated_1rm and repetitions and weight and start_dates and messages: + exercise_progress = get_exercise_graph_model( + title=exercise_name, + estimated_1rm=estimated_1rm, + repetitions=repetitions, + weight=weight, + start_dates=start_dates, + messages=messages, + epoch=epoch, + person_id=person_id, + exercise_id=exercise_id, + ) + + return exercise_progress + + + def get(self, selected_people_ids, start_date, end_date, selected_exercise_ids): + # Create placeholders for selected_people_ids and selected_exercise_ids + people_placeholders = ", ".join(["%s"] * len(selected_people_ids)) + exercise_placeholders = ", ".join(["%s"] * len(selected_exercise_ids)) + + # SQL query to fetch data + sql_query = f""" + SELECT + p.person_id, + p.name AS person_name, + e.exercise_id, + e.name AS exercise_name, + t.topset_id, + t.repetitions, + t.weight, + w.start_date AS workout_date, + w.workout_id + FROM + person p + JOIN + workout w ON p.person_id = w.person_id + JOIN + topset t ON w.workout_id = t.workout_id + JOIN + exercise e ON t.exercise_id = e.exercise_id + WHERE + p.person_id IN ({people_placeholders}) + AND w.start_date BETWEEN %s AND %s + AND e.exercise_id IN ({exercise_placeholders}) + ORDER BY + p.person_id ASC, e.exercise_id ASC, t.topset_id DESC; + """ + + # Add parameters for the query + params = selected_people_ids + [start_date, end_date] + selected_exercise_ids + + # Execute the query + result = self.execute(sql_query, params) + + # Handle empty result + if not result: + return {"people": []} + + # Organize data into the desired structure + people_map = {} + + for row in result: + # Person level + person_id = row["person_id"] + if person_id not in people_map: + people_map[person_id] = { + "id": person_id, + "name": row["person_name"], + "exercises": {} + } + + # Exercise level + exercise_id = row["exercise_id"] + person_exercises = people_map[person_id]["exercises"] + if exercise_id not in person_exercises: + person_exercises[exercise_id] = { + "id": exercise_id, + "name": row["exercise_name"], + "sets": [] + } + + # Set level + person_exercises[exercise_id]["sets"].append({ + "id": row["topset_id"], + "reps": row["repetitions"], + "weight": row["weight"], + "exercise_id": row["exercise_id"], + "exercise_name": row["exercise_name"], + "workout_id": row["workout_id"], + "workout_start_date": row["workout_date"], + "estimated_1rm": calculate_estimated_1rm(row["weight"], row["repetitions"]) + }) + + # Convert the map into a list of people, generate graphs, and organize exercises + people = [] + for person_id, person_data in people_map.items(): + exercises = [] + for exercise_id, exercise_data in person_data["exercises"].items(): + # Sort sets by timestamp (descending) + exercise_data["sets"] = sorted( + exercise_data["sets"], key=lambda x: x["id"], reverse=True + ) + + # Generate the graph for the exercise + graph = self.generate_exercise_progress_graphs( + person_id=person_id, + exercise_id=exercise_id, + exercise_name=exercise_data["name"], + exercise_sets=exercise_data["sets"] + ) + + # Add the graph to the exercise data + exercises.append({ + "id": exercise_data["id"], + "name": exercise_data["name"], + "graph": graph, + "sets": exercise_data["sets"] + }) + person_data["exercises"] = exercises + people.append(person_data) + + return {"dashboard": people} + + + + diff --git a/templates/dashboard.html b/templates/dashboard.html index 6f6a7a1..54791a4 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -20,10 +20,8 @@ placeholder: 'Filter people', }) end"> - {% for p in people %} - {% endfor %} @@ -47,9 +45,9 @@ placeholder: 'Filter exercises', }) end"> - {% for e in exercises %} - + {% for exercise in exercises %} + {% endfor %} @@ -102,7 +100,7 @@ hx-target="this" hx-swap="outerHTML"> -{% if model['People']|length == 0 %} +{% if dashboard|length == 0 %} @@ -110,36 +108,35 @@
- {% for p in model['People'] %} + {% for person in dashboard %}
-

{{ p['PersonName'] }}

+

{{ person.name }}

Current rep maxes
- {% if p['NumberOfWorkouts'] == 0 %} + {% if person.exercises|length == 0 %} {% endif %} - {% for e in p['Exercises'] %} - {% if e['Topsets']|length > 1 %} + {% for exercise in person.exercises %} + {% if exercise.sets|length > 1 %}
- {{ render_partial('partials/sparkline.html', **e['ExerciseProgressGraph']) }} + {{ render_partial('partials/sparkline.html', **exercise.graph) }}
@@ -161,16 +158,19 @@ - {% for rm in e['Topsets'] %} - + {% for set in exercise.sets %} + {% endfor %} diff --git a/utils.py b/utils.py index 81ebbc9..5f435a5 100644 --- a/utils.py +++ b/utils.py @@ -5,119 +5,6 @@ import pandas as pd import plotly.express as px import plotly.io as pio -def get_workouts(topsets): - # Ensure all entries have 'WorkoutId' and 'TopSetId', then sort by 'WorkoutId' and 'TopSetId' - filtered_topsets = sorted( - [t for t in topsets if t['WorkoutId'] is not None and t['TopSetId'] is not None], - key=lambda x: (x['WorkoutId'], x['TopSetId']) - ) - - workouts = {} - for t in filtered_topsets: - workout_id = t['WorkoutId'] - if workout_id not in workouts: - workouts[workout_id] = { - 'WorkoutId': workout_id, - 'StartDate': t['StartDate'], - 'TopSets': [] - } - workouts[workout_id]['TopSets'].append({ - 'TopSetId': t['TopSetId'], - 'ExerciseId': t['ExerciseId'], - 'ExerciseName': t['ExerciseName'], - 'Weight': t['Weight'], - 'Repetitions': t['Repetitions'], - 'Estimated1RM': t['Estimated1RM'] - }) - - # Convert the workouts dictionary back to a list and sort by 'StartDate' - sorted_workouts = sorted(workouts.values(), key=lambda x: x['StartDate'], reverse=True) - - return sorted_workouts - - -def get_all_exercises_from_topsets(topsets): - exercises_dict = {} - for t in topsets: - exercise_id = t.get('ExerciseId') - if exercise_id and exercise_id not in exercises_dict: - exercises_dict[exercise_id] = { - 'ExerciseId': exercise_id, - 'ExerciseName': t.get('ExerciseName', 'Unknown') - } - return list(exercises_dict.values()) - -def get_topsets_for_person(person_topsets): - # Group topsets by ExerciseId - grouped_topsets = {} - for topset in person_topsets: - exercise_id = topset['ExerciseId'] - if exercise_id in grouped_topsets: - grouped_topsets[exercise_id].append(topset) - else: - grouped_topsets[exercise_id] = [topset] - - # Process each group of topsets - exercises_topsets = [] - for exercise_id, topsets in grouped_topsets.items(): - # Sort topsets by StartDate in descending order - sorted_topsets = sorted(topsets, key=lambda x: x['StartDate'], reverse=True) - - # Extracting values and calculating value ranges for SVG dimensions - estimated_1rm = [t['Estimated1RM'] for t in sorted_topsets] - repetitions = [t['Repetitions'] for t in sorted_topsets] - weight = [t['Weight'] for t in sorted_topsets] - start_dates = [t['StartDate'] for t in sorted_topsets] - messages = [f'{t["Repetitions"]} x {t["Weight"]}kg ({t["Estimated1RM"]}kg E1RM) on {t["StartDate"].strftime("%d %b %y")}' for t in sorted_topsets] - epoch = 'All' - person_id = sorted_topsets[0]['PersonId'] - exercise_name = sorted_topsets[0]['ExerciseName'] - - if exercise_name and estimated_1rm and repetitions and weight and start_dates and messages: - exercise_progress = get_exercise_graph_model(exercise_name, estimated_1rm, repetitions, weight, start_dates, messages, epoch, person_id, exercise_id) - - exercises_topsets.append({ - 'ExerciseId': exercise_id, - 'ExerciseName': exercise_name, - 'Topsets': sorted_topsets, - 'ExerciseProgressGraph': exercise_progress - }) - - return exercises_topsets - -def get_people_and_exercise_rep_maxes(topsets, selected_person_ids, selected_exercise_ids, min_date, max_date): - # Filter topsets once based on the criteria - filtered_topsets = [ - t for t in topsets if t['PersonId'] in selected_person_ids - and t['ExerciseId'] in selected_exercise_ids - and min_date <= t['StartDate'] <= max_date - ] - - # Group the filtered topsets by PersonId - grouped_by_person = {} - for t in filtered_topsets: - person_id = t['PersonId'] - if person_id in grouped_by_person: - grouped_by_person[person_id].append(t) - else: - grouped_by_person[person_id] = [t] - - people = [] - for person_id, person_topsets in grouped_by_person.items(): - person_name = person_topsets[0]['PersonName'] - workout_ids = {t['WorkoutId'] for t in person_topsets if t['WorkoutId']} - number_of_workouts = len(workout_ids) - - people.append({ - 'PersonId': person_id, - 'PersonName': person_name, - 'NumberOfWorkouts': number_of_workouts, - 'Exercises': get_topsets_for_person(person_topsets) - }) - - return {"People": people} - - def convert_str_to_date(date_str, format='%Y-%m-%d'): try: return datetime.strptime(date_str, format).date() @@ -126,35 +13,6 @@ def convert_str_to_date(date_str, format='%Y-%m-%d'): except TypeError: return None - - -def flatten_list(list_of_lists): - return [item for sublist in list_of_lists for item in sublist] - - -def first_and_last_visible_days_in_month(first_day_of_month, last_day_of_month): - start = dict([(6, 0), (0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)]) - start_date = first_day_of_month - \ - timedelta(days=start[first_day_of_month.weekday()]) - - end = dict([(6, 6), (0, 5), (1, 4), (2, 3), (3, 2), (4, 1), (5, 0)]) - end_date = last_day_of_month + \ - timedelta(days=end[last_day_of_month.weekday()]) - return (start_date, end_date) - - -def flatten(lst): - """ - Flatten a list of lists. - """ - result = [] - for item in lst: - if isinstance(item, list): - result.extend(flatten(item)) - else: - result.append(item) - return result - def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_dates, messages, epoch, person_id, exercise_id, min_date=None, max_date=None): # Precompute ranges min_date, max_date = min(start_dates), max(start_dates)
- {{ rm['StartDate'] }} + {{ set.workout_start_date | strftime("%b %d %Y") }} - {{ rm['Repetitions'] }} x {{ rm['Weight'] }}kg + {{ set.reps }} x {{ set.weight }}kg - {{ rm['Estimated1RM'] }}kg + {{ set.estimated_1rm }}kg