|
|
|
|
@@ -105,6 +105,17 @@ class HeartRateReading(db.Model):
|
|
|
|
|
bpm = db.Column(db.Integer, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserGraphs(db.Model):
|
|
|
|
|
__tablename__ = 'user_graphs'
|
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
|
user_id = db.Column(db.Integer, db.ForeignKey(
|
|
|
|
|
'users.id', ondelete='CASCADE'), nullable=False)
|
|
|
|
|
start_date = db.Column(db.DateTime, nullable=False)
|
|
|
|
|
end_date = db.Column(db.DateTime, nullable=False)
|
|
|
|
|
period = db.Column(db.String(255), nullable=False)
|
|
|
|
|
attributes = db.Column(db.ARRAY(db.String(255)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/', methods=['GET'])
|
|
|
|
|
def overview():
|
|
|
|
|
return render_users_and_workouts()
|
|
|
|
|
@@ -216,35 +227,52 @@ def create_workout(user_id):
|
|
|
|
|
|
|
|
|
|
@app.route('/user/<int:user_id>/workouts/graph', methods=['GET'])
|
|
|
|
|
def graph_user_workouts(user_id):
|
|
|
|
|
attributes = request.args.getlist(
|
|
|
|
|
'attributes', type=str)
|
|
|
|
|
if not attributes:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
if htmx:
|
|
|
|
|
return f"""
|
|
|
|
|
<img src="{request.full_path}"
|
|
|
|
|
loading="lazy" alt="No image" class="mx-auto" _="on click remove me">
|
|
|
|
|
"""
|
|
|
|
|
user = User.query.get(user_id)
|
|
|
|
|
workouts = user.workouts
|
|
|
|
|
# workouts = user.workouts
|
|
|
|
|
|
|
|
|
|
earliest_workout_date = min([workout.created_at.date()
|
|
|
|
|
workouts = get_workouts_for_user_view_data(user)
|
|
|
|
|
|
|
|
|
|
earliest_workout_date = min([workout['start_time_date']
|
|
|
|
|
for workout in workouts])
|
|
|
|
|
most_recent_workout_date = max(
|
|
|
|
|
[workout.created_at.date() for workout in workouts])
|
|
|
|
|
[workout['start_time_date'] for workout in workouts])
|
|
|
|
|
|
|
|
|
|
start_date = request.args.get(
|
|
|
|
|
'start_date', default=earliest_workout_date, type=toDate)
|
|
|
|
|
end_date = request.args.get(
|
|
|
|
|
'end_date', default=most_recent_workout_date, type=toDate)
|
|
|
|
|
|
|
|
|
|
attributes = request.args.getlist(
|
|
|
|
|
'attributes', type=str)
|
|
|
|
|
if not attributes:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
period = request.args.get('period', default='day', type=str)
|
|
|
|
|
|
|
|
|
|
user_data = generate_user_data(user)
|
|
|
|
|
title = f'{format_key_values(user_data["attributes"], attributes)} over {get_value_from_key(user_data["periods"], period)} ({start_date.strftime("%d/%m/%y")} - {end_date.strftime("%d/%m/%y")})'
|
|
|
|
|
# Add record of user graph
|
|
|
|
|
insert_usergraph_if_not_exists(
|
|
|
|
|
user_id, start_date, end_date, period, attributes)
|
|
|
|
|
|
|
|
|
|
return plot_averaged_attributes(workouts, start_date, end_date, period, attributes, title)
|
|
|
|
|
return plot_averaged_attributes(workouts, user, start_date, end_date, period, attributes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user/<int:user_id>/workouts/graph/delete', methods=['DELETE'])
|
|
|
|
|
def delete_user_graph(user_id):
|
|
|
|
|
attributes = request.args.getlist(
|
|
|
|
|
'attributes', type=str)
|
|
|
|
|
start_date = request.args.get(
|
|
|
|
|
'start_date', default=datetime.now().date(), type=toDate)
|
|
|
|
|
end_date = request.args.get(
|
|
|
|
|
'end_date', default=datetime.now().date(), type=toDate)
|
|
|
|
|
period = request.args.get('period', default='day', type=str)
|
|
|
|
|
|
|
|
|
|
remove_usergraph(user_id, start_date, end_date, period, attributes)
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def daterange(start_date, end_date, delta=timedelta(days=1)):
|
|
|
|
|
@@ -257,7 +285,7 @@ def daterange(start_date, end_date, delta=timedelta(days=1)):
|
|
|
|
|
|
|
|
|
|
def average_workout_attributes_per_period(workouts, start_date, end_date, period, attributes):
|
|
|
|
|
"""
|
|
|
|
|
Returns a dictionary of averaged attributes for workouts within each given period
|
|
|
|
|
Returns a dictionary of averaged attributes for workouts within each given period
|
|
|
|
|
between start_date and end_date.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
@@ -286,17 +314,16 @@ def average_workout_attributes_per_period(workouts, start_date, end_date, period
|
|
|
|
|
for start_period in daterange(start_date, end_date, delta):
|
|
|
|
|
end_period = start_period + delta
|
|
|
|
|
filtered_workouts = [
|
|
|
|
|
w for w in workouts if start_period <= w.created_at.date() < end_period]
|
|
|
|
|
w for w in workouts if start_period <= w['start_time_date'] < end_period]
|
|
|
|
|
|
|
|
|
|
for attribute in attributes:
|
|
|
|
|
if hasattr(Workout, attribute):
|
|
|
|
|
valid_values = [getattr(w, attribute) for w in filtered_workouts if getattr(
|
|
|
|
|
w, attribute) is not None]
|
|
|
|
|
if valid_values:
|
|
|
|
|
average = sum(valid_values) / len(valid_values)
|
|
|
|
|
results[start_period][attribute] = average
|
|
|
|
|
else:
|
|
|
|
|
results[start_period][attribute] = 0 # None
|
|
|
|
|
valid_values = [w.get(attribute) for w in filtered_workouts if w.get(
|
|
|
|
|
attribute) is not None]
|
|
|
|
|
if valid_values:
|
|
|
|
|
average = sum(valid_values) / len(valid_values)
|
|
|
|
|
results[start_period][attribute] = average
|
|
|
|
|
else:
|
|
|
|
|
results[start_period][attribute] = 0 # None
|
|
|
|
|
|
|
|
|
|
results[start_period]['workout_count'] = len(
|
|
|
|
|
filtered_workouts)
|
|
|
|
|
@@ -334,7 +361,8 @@ def create_user_graph(x_values, y_data, filename, x_label='Time', title=None):
|
|
|
|
|
|
|
|
|
|
# Save the graph to a bytes buffer
|
|
|
|
|
buffer = io.BytesIO()
|
|
|
|
|
plt.savefig(buffer, format='png', transparent=True, bbox_inches='tight')
|
|
|
|
|
plt.savefig(buffer, format='png',
|
|
|
|
|
transparent=True, bbox_inches='tight')
|
|
|
|
|
buffer.seek(0)
|
|
|
|
|
|
|
|
|
|
# Create a response object with the graph image
|
|
|
|
|
@@ -345,7 +373,7 @@ def create_user_graph(x_values, y_data, filename, x_label='Time', title=None):
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def plot_averaged_attributes(workouts_list, start_date, end_date, period, attributes, title):
|
|
|
|
|
def plot_averaged_attributes(workouts_list, user, start_date, end_date, period, attributes):
|
|
|
|
|
"""Creates a graph for averaged attributes over a period.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
@@ -356,6 +384,9 @@ def plot_averaged_attributes(workouts_list, start_date, end_date, period, attrib
|
|
|
|
|
Returns:
|
|
|
|
|
- Flask Response object containing the image of the graph.
|
|
|
|
|
"""
|
|
|
|
|
user_data = generate_user_data(user)
|
|
|
|
|
title = f'{format_key_values(user_data["attributes"], attributes)} over {get_value_from_key(user_data["periods"], period)} ({start_date.strftime("%d/%m/%y")} - {end_date.strftime("%d/%m/%y")})'
|
|
|
|
|
|
|
|
|
|
# Fetching the data
|
|
|
|
|
averaged_attributes = average_workout_attributes_per_period(
|
|
|
|
|
workouts_list, start_date, end_date, period, attributes)
|
|
|
|
|
@@ -365,14 +396,14 @@ def plot_averaged_attributes(workouts_list, start_date, end_date, period, attrib
|
|
|
|
|
|
|
|
|
|
y_data = {}
|
|
|
|
|
for attribute in attributes:
|
|
|
|
|
y_data[attribute] = [averaged_attributes[date][attribute]
|
|
|
|
|
for date in x_values]
|
|
|
|
|
y_data[get_value_from_key(user_data["attributes"], attribute)] = [averaged_attributes[date][attribute]
|
|
|
|
|
for date in x_values]
|
|
|
|
|
|
|
|
|
|
# Creating the graph
|
|
|
|
|
return create_user_graph(x_values, y_data, filename=f"average_attributes_over_{period}", title=title)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user/<int:user_id>/workout/<int:workout_id>/<string:graph_type>', methods=['GET'])
|
|
|
|
|
@ app.route('/user/<int:user_id>/workout/<int:workout_id>/<string:graph_type>', methods=['GET'])
|
|
|
|
|
def workout(user_id, workout_id, graph_type):
|
|
|
|
|
workout = Workout.query.filter_by(user_id=user_id, id=workout_id) \
|
|
|
|
|
.join(Workout.cadence_readings) \
|
|
|
|
|
@@ -403,15 +434,15 @@ def workout(user_id, workout_id, graph_type):
|
|
|
|
|
return jsonify({'message': f'Unable to generate {graph_type} for workout {workout_id}.'}), 409
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user/<int:user_id>/workout/<int:workout_id>/view', methods=['GET'])
|
|
|
|
|
@ app.route('/user/<int:user_id>/workout/<int:workout_id>/view', methods=['GET'])
|
|
|
|
|
def view_workout(user_id, workout_id):
|
|
|
|
|
workout = Workout.query.filter_by(user_id=user_id, id=workout_id).first()
|
|
|
|
|
graph_types = request.args.getlist('graph_types')
|
|
|
|
|
return render_template('workout_view.html', workout=workout, graph_types=graph_types)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user/<int:user_id>/workout/<int:workout_id>/delete', methods=['DELETE'])
|
|
|
|
|
@basic_auth.required
|
|
|
|
|
@ app.route('/user/<int:user_id>/workout/<int:workout_id>/delete', methods=['DELETE'])
|
|
|
|
|
@ basic_auth.required
|
|
|
|
|
def delete_workout(user_id, workout_id):
|
|
|
|
|
# Delete the workout and its associated cadence readings
|
|
|
|
|
CadenceReading.query.filter_by(workout_id=workout_id).delete()
|
|
|
|
|
@@ -421,7 +452,7 @@ def delete_workout(user_id, workout_id):
|
|
|
|
|
return render_users_and_workouts()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user/<int:user_id>/bike', methods=['GET'])
|
|
|
|
|
@ app.route('/user/<int:user_id>/bike', methods=['GET'])
|
|
|
|
|
def update_users_bike(user_id):
|
|
|
|
|
bike_id = request.args.get('bike_id')
|
|
|
|
|
user = User.query.get(user_id)
|
|
|
|
|
@@ -438,7 +469,8 @@ def update_users_bike(user_id):
|
|
|
|
|
def calendar_view(user_id):
|
|
|
|
|
user = User.query.get(user_id)
|
|
|
|
|
workouts = get_workouts_for_user_view_data(user)
|
|
|
|
|
date = request.args.get('date', default=datetime.now().date(), type=toDate)
|
|
|
|
|
date = request.args.get(
|
|
|
|
|
'date', default=datetime.now().date(), type=toDate)
|
|
|
|
|
calendar_month = generate_calendar_monthly_view(workouts, date)
|
|
|
|
|
return render_template('partials/calendar.html', calendar_month=calendar_month, user_id=user_id)
|
|
|
|
|
|
|
|
|
|
@@ -480,8 +512,11 @@ def generate_user_data(user, workouts=[]):
|
|
|
|
|
'workouts': workouts,
|
|
|
|
|
'daily_duration_sparkline': generate_daily_duration_sparkline(workouts),
|
|
|
|
|
'calendar_month': generate_calendar_monthly_view(workouts, datetime.now().date()),
|
|
|
|
|
'attributes': [('workout_count', 'Workout count'), ('duration', 'Duration'), ('average_rpm', 'Average RPM'), ('average_bpm', 'Average BPM'), ('distance', 'Distance'), ('calories', 'Calories')],
|
|
|
|
|
'attributes': [('workout_count', 'Workout count'), ('duration_seconds', 'Duration (sec)'), ('duration_minutes', 'Duration (min)'), ('average_rpm', 'Average RPM'), ('max_rpm', 'Max RPM'), ('average_bpm', 'Average BPM'), ('max_bpm', 'Max BPM'), ('distance', 'Distance'), ('calories', 'Calories')],
|
|
|
|
|
'periods': [('day', 'Day'), ('week', 'Week'), ('month', 'Month')],
|
|
|
|
|
# (period: str, attributes: [str])
|
|
|
|
|
# 'graphs': [('month', ['duration_minutes']), ('week', ['average_rpm', 'average_bpm']), ('week', ['workout_count'])],
|
|
|
|
|
'graphs': get_user_graphs(user.id),
|
|
|
|
|
'first_workout_date': workouts[-1]['start_time_date'] if workouts else None,
|
|
|
|
|
'last_workout_date': workouts[0]['start_time_date'] if workouts else None,
|
|
|
|
|
}
|
|
|
|
|
@@ -535,6 +570,7 @@ def format_workout_data(workout, user):
|
|
|
|
|
'start_time_date': workout.started_at.date(),
|
|
|
|
|
'start_time_ago': humanize.naturaltime(workout.started_at),
|
|
|
|
|
'duration': humanize.naturaldelta(duration),
|
|
|
|
|
'duration_seconds': duration.total_seconds(),
|
|
|
|
|
'duration_minutes': duration.total_seconds() / 60,
|
|
|
|
|
'average_rpm': int(workout.average_rpm or 0),
|
|
|
|
|
'min_rpm': int(workout.min_rpm or 0),
|
|
|
|
|
@@ -632,7 +668,8 @@ def generate_calendar_monthly_view(workouts, selected_date):
|
|
|
|
|
start_date, end_date = get_month_bounds(selected_date)
|
|
|
|
|
|
|
|
|
|
# Build a lookup dictionary for faster access
|
|
|
|
|
workout_lookup = {w['start_time_date'] : w for w in workouts if start_date <= w['start_time_date'] <= end_date}
|
|
|
|
|
workout_lookup = {w['start_time_date']
|
|
|
|
|
: w for w in workouts if start_date <= w['start_time_date'] <= end_date}
|
|
|
|
|
|
|
|
|
|
current_date = datetime.now().date()
|
|
|
|
|
days_of_month = [
|
|
|
|
|
@@ -674,7 +711,7 @@ def generate_daily_duration_sparkline(workouts):
|
|
|
|
|
|
|
|
|
|
# Determine date range based on workouts data
|
|
|
|
|
start_date = workouts[-1]['start_time_date']
|
|
|
|
|
end_date = workouts[0]['start_time_date']
|
|
|
|
|
end_date = datetime.now().date() # workouts[0]['start_time_date']
|
|
|
|
|
|
|
|
|
|
# Build a mapping of dates to their respective durations for easier lookup
|
|
|
|
|
workouts_by_date = {w['start_time_date']: int(
|
|
|
|
|
@@ -689,6 +726,61 @@ def generate_daily_duration_sparkline(workouts):
|
|
|
|
|
return sparklines.sparklines(daily_durations)[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_user_graphs(user_id):
|
|
|
|
|
"""Retrieve a list of UserGraphs entries for the given user_id."""
|
|
|
|
|
|
|
|
|
|
user_graphs = UserGraphs.query.filter_by(
|
|
|
|
|
user_id=user_id).order_by(UserGraphs.id.desc()).all()
|
|
|
|
|
|
|
|
|
|
# change start_date, end_date from datetime to dates
|
|
|
|
|
for user_graph in user_graphs:
|
|
|
|
|
user_graph.start_date = user_graph.start_date.date()
|
|
|
|
|
user_graph.end_date = user_graph.end_date.date()
|
|
|
|
|
|
|
|
|
|
return user_graphs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def insert_usergraph_if_not_exists(user_id, start_date, end_date, period, attributes):
|
|
|
|
|
"""Insert a UserGraphs entry if it doesn't already exist based on specified attributes and return its ID."""
|
|
|
|
|
|
|
|
|
|
existing_graph = UserGraphs.query.filter_by(
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
period=period,
|
|
|
|
|
attributes=attributes
|
|
|
|
|
).first()
|
|
|
|
|
|
|
|
|
|
if not existing_graph:
|
|
|
|
|
new_graph = UserGraphs(
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
period=period,
|
|
|
|
|
attributes=attributes
|
|
|
|
|
)
|
|
|
|
|
db.session.add(new_graph)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
return new_graph.id # Return the ID of the newly added object
|
|
|
|
|
return None # Return None if the record already exists
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def remove_usergraph(user_id, start_date, end_date, period, attributes):
|
|
|
|
|
"""Remove a UserGraphs entry based on specified attributes."""
|
|
|
|
|
|
|
|
|
|
existing_graph = UserGraphs.query.filter_by(
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
period=period,
|
|
|
|
|
attributes=attributes
|
|
|
|
|
).first()
|
|
|
|
|
|
|
|
|
|
if existing_graph:
|
|
|
|
|
db.session.delete(existing_graph)
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def toDate(dateString):
|
|
|
|
|
return datetime.strptime(dateString, "%Y-%m-%d").date()
|
|
|
|
|
|
|
|
|
|
|