Compare commits

...

10 Commits

4 changed files with 139 additions and 45 deletions

1
.buildpacks Normal file
View File

@@ -0,0 +1 @@
https://github.com/heroku/heroku-buildpack-python#v210

160
app.py
View File

@@ -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()

View File

@@ -1,4 +1,4 @@
<div class="flex flex-wrap mb-1">
<div class="flex flex-wrap mb-5">
<div class="w-full md:w-1/5 px-3 mb-6 md:mb-0">
<div class="mb-1 w-full">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
@@ -6,7 +6,7 @@
</label>
<select data-te-select-init data-te-select-size="lg" name="attributes"
class="bg-gray-50 border border-gray-300 " multiple _="init js(me)
te.Select.getOrCreateInstance(me).setValue({{ ['workout_count'] }})
te.Select.getOrCreateInstance(me).setValue({{ [] }})
end">
{% for val,name in user.attributes %}
<option value="{{ val }}">{{ name }}</option>
@@ -89,8 +89,10 @@
</div>
<div id="user-workouts-overview-graphs-{{ user.id }}">
<img src="{{ url_for('graph_user_workouts', user_id=user.id, period='week', attributes=['workout_count']) }}"
loading="lazy" alt="No image" class="mx-auto" _="on click remove me">
<img src="{{ url_for('graph_user_workouts', user_id=user.id, period='month', attributes=['duration']) }}"
loading="lazy" alt="No image" class="mx-auto" _="on click remove me">
{% for graph in user.graphs %}
<img src="{{ url_for('graph_user_workouts', user_id=user.id, start_date=graph.start_date, end_date=graph.end_date, period=graph.period, attributes=graph.attributes) }}"
loading="lazy" alt="No image" class="mx-auto"
hx-delete="{{ url_for('delete_user_graph', user_id=user.id, start_date=graph.start_date, end_date=graph.end_date, period=graph.period, attributes=graph.attributes ) }}"
hx-swap="outerHTML">
{% endfor %}
</div>

View File

@@ -75,12 +75,11 @@
<div class="!visible collapse p-4 hidden" id="workouts-list-accordion-{{ user.id }}">
{{ render_partial('partials/calendar.html', calendar_month=user.calendar_month, user_id = user.id) }}
{{ render_partial('partials/user_workouts_graphs.html', user=user) }}
<div id="workouts-list-wrapper-for-user-{{ user.id }}" class="mt-5 pl-2">{{
render_partial('partials/workouts_list_fragment.html',
workouts=workouts[:7], user_id = user.id) }}</div>
<div id="workouts-list-wrapper-for-user-{{ user.id }}" class="mt-5 pl-2">
{{ render_partial('partials/user_workouts_graphs.html', user=user) }}
{{ render_partial('partials/workouts_list_fragment.html', workouts=workouts[:7], user_id = user.id) }}
</div>
</div>
{% endif %}