Compare commits
10 Commits
ead73e8190
...
dd82f461be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd82f461be | ||
|
|
e7d125d57b | ||
|
|
c88d28b47c | ||
|
|
7aa7f9b8dc | ||
|
|
e947feb3e3 | ||
|
|
51ec18c461 | ||
|
|
3da0dc3b3d | ||
|
|
62e203bc2a | ||
|
|
2d67badd32 | ||
|
|
64dda01af6 |
37
app.py
37
app.py
@@ -12,6 +12,9 @@ from routes.notes import notes_bp # Import the new notes blueprint
|
||||
from routes.workout import workout_bp # Import the new workout blueprint
|
||||
from routes.sql_explorer import sql_explorer_bp # Import the new SQL explorer blueprint
|
||||
from routes.endpoints import endpoints_bp # Import the new endpoints blueprint
|
||||
from routes.export import export_bp # Import the new export blueprint
|
||||
from routes.tags import tags_bp # Import the new tags blueprint
|
||||
from routes.programs import programs_bp # Import the new programs blueprint
|
||||
from extensions import db
|
||||
from utils import convert_str_to_date, generate_plot
|
||||
from flask_htmx import HTMX
|
||||
@@ -44,6 +47,9 @@ app.register_blueprint(notes_bp) # Register the notes blueprint
|
||||
app.register_blueprint(workout_bp) # Register the workout blueprint
|
||||
app.register_blueprint(sql_explorer_bp) # Register the SQL explorer blueprint (prefix defined in blueprint file)
|
||||
app.register_blueprint(endpoints_bp) # Register the endpoints blueprint (prefix defined in blueprint file)
|
||||
app.register_blueprint(export_bp) # Register the export blueprint (prefix defined in blueprint file)
|
||||
app.register_blueprint(tags_bp) # Register the tags blueprint (prefix defined in blueprint file)
|
||||
app.register_blueprint(programs_bp) # Register the programs blueprint (prefix defined in blueprint file)
|
||||
|
||||
@app.after_request
|
||||
def response_minify(response):
|
||||
@@ -210,36 +216,7 @@ def settings():
|
||||
return render_template('settings.html', people=people, exercises=exercises)
|
||||
|
||||
|
||||
@ app.route("/tag/redirect", methods=['GET'])
|
||||
def goto_tag():
|
||||
person_id = request.args.get("person_id")
|
||||
tag_filter = request.args.get('filter')
|
||||
if person_id:
|
||||
return redirect(url_for('person_overview', person_id=int(person_id)) + tag_filter)
|
||||
return redirect(url_for('dashboard') + tag_filter)
|
||||
|
||||
|
||||
@ app.route("/tag/add", methods=['GET'])
|
||||
def add_tag():
|
||||
person_id = request.args.get("person_id")
|
||||
tag = request.args.get('tag')
|
||||
tag_filter = request.args.get('filter')
|
||||
if person_id:
|
||||
db.add_or_update_tag_for_person(person_id, tag, tag_filter)
|
||||
else:
|
||||
db.add_or_update_tag_for_dashboard(tag, tag_filter)
|
||||
return ""
|
||||
|
||||
|
||||
@ app.route("/tag/<int:tag_id>/delete", methods=['GET'])
|
||||
def delete_tag(tag_id):
|
||||
person_id = request.args.get("person_id")
|
||||
tag_filter = request.args.get("filter")
|
||||
if person_id:
|
||||
db.delete_tag_for_person(person_id=person_id, tag_id=tag_id)
|
||||
return redirect(url_for('get_person', person_id=person_id) + tag_filter)
|
||||
db.delete_tag_for_dashboard(tag_id)
|
||||
return redirect(url_for('dashboard') + tag_filter)
|
||||
# Routes moved to routes/tags.py blueprint
|
||||
|
||||
@ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET'])
|
||||
def get_exercise_progress_for_user(person_id, exercise_id):
|
||||
|
||||
82
db.py
82
db.py
@@ -341,88 +341,6 @@ class DataBase():
|
||||
def delete_tag_for_dashboard(self, tag_id):
|
||||
self.execute('DELETE FROM Tag WHERE tag_id=%s', [tag_id], commit=True)
|
||||
|
||||
# Note update logic moved to routes/notes.py
|
||||
|
||||
def add_tag_for_workout(self, workout_id, tags_id):
|
||||
# If tags_id is not empty, delete tags that are not in the new selection
|
||||
if tags_id:
|
||||
self.execute(
|
||||
"""
|
||||
DELETE FROM workout_tag
|
||||
WHERE workout_id = %s AND tag_id NOT IN %s
|
||||
""",
|
||||
[workout_id, tuple(tags_id)], commit=True
|
||||
)
|
||||
else:
|
||||
# If tags_id is empty, delete all tags for this workout
|
||||
self.execute(
|
||||
"""
|
||||
DELETE FROM workout_tag
|
||||
WHERE workout_id = %s
|
||||
""",
|
||||
[workout_id], commit=True
|
||||
)
|
||||
|
||||
# Then, attempt to insert the new tags
|
||||
for tag_id in tags_id:
|
||||
self.execute(
|
||||
"""
|
||||
INSERT INTO workout_tag (workout_id, tag_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (workout_id, tag_id) DO NOTHING
|
||||
""",
|
||||
[workout_id, tag_id], commit=True
|
||||
)
|
||||
|
||||
# Now fetch updated list of workout tags
|
||||
workout_tags = self.execute("""
|
||||
SELECT
|
||||
T.tag_id AS "tag_id",
|
||||
T.person_id AS "person_id",
|
||||
T.name AS "tag_name",
|
||||
T.filter AS "tag_filter",
|
||||
TRUE AS "is_selected"
|
||||
FROM Workout_Tag WT
|
||||
LEFT JOIN Tag T ON WT.tag_id=T.tag_id
|
||||
WHERE WT.workout_id=%s""", [workout_id])
|
||||
|
||||
return workout_tags
|
||||
|
||||
def create_tag_for_workout(self, person_id, workout_id, tag_name):
|
||||
workout_exercises = self.execute("""
|
||||
SELECT
|
||||
E.exercise_id AS "exercise_id",
|
||||
E.name AS "exercise_name"
|
||||
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.workout_id=%s""", [workout_id])
|
||||
|
||||
tag_filter = "?" + \
|
||||
"&".join(
|
||||
f"exercise_id={e['exercise_id']}" for e in workout_exercises)
|
||||
|
||||
# create tag for person
|
||||
row = self.execute('INSERT INTO Tag (person_id, name, filter) VALUES (%s, %s, %s) RETURNING tag_id AS "tag_id"', [
|
||||
person_id, tag_name, tag_filter], commit=True, one=True)
|
||||
|
||||
# add tag to workout
|
||||
self.execute('INSERT INTO Workout_Tag (workout_id, tag_id) VALUES (%s, %s)', [
|
||||
workout_id, row['tag_id']], commit=True)
|
||||
|
||||
# Now fetch updated list of workout tags
|
||||
workout_tags = self.execute("""
|
||||
SELECT
|
||||
T.tag_id AS "tag_id",
|
||||
T.person_id AS "person_id",
|
||||
T.name AS "tag_name",
|
||||
T.filter AS "tag_filter"
|
||||
FROM Workout_Tag WT
|
||||
LEFT JOIN Tag T ON WT.tag_id=T.tag_id
|
||||
WHERE WT.workout_id=%s""", [workout_id])
|
||||
|
||||
return workout_tags
|
||||
|
||||
def get_workout_tags(self, person_id, workout_id):
|
||||
person_tags = self.execute("""
|
||||
SELECT
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import pandas as pd
|
||||
from utils import get_distinct_colors
|
||||
|
||||
from utils import get_distinct_colors, calculate_estimated_1rm
|
||||
|
||||
class PeopleGraphs:
|
||||
def __init__(self, db_connection_method):
|
||||
self.execute = db_connection_method
|
||||
|
||||
def get(self, selected_people_ids=None, min_date=None, max_date=None, selected_exercise_ids=None):
|
||||
# Base query
|
||||
"""
|
||||
Fetch workout topsets, calculate Estimated1RM in Python,
|
||||
then generate weekly workout & PR graphs.
|
||||
"""
|
||||
# Build query (no in-SQL 1RM calculation).
|
||||
query = """
|
||||
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",
|
||||
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",
|
||||
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"
|
||||
T.weight AS "Weight"
|
||||
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
|
||||
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 TRUE
|
||||
"""
|
||||
|
||||
# Parameters for the query
|
||||
params = []
|
||||
|
||||
# Add optional filters
|
||||
if selected_people_ids:
|
||||
placeholders = ", ".join(["%s"] * len(selected_people_ids))
|
||||
query += f" AND P.person_id IN ({placeholders})"
|
||||
query += f" AND P.person_id IN ({', '.join(['%s'] * len(selected_people_ids))})"
|
||||
params.extend(selected_people_ids)
|
||||
if min_date:
|
||||
query += " AND W.start_date >= %s"
|
||||
@@ -42,143 +39,233 @@ class PeopleGraphs:
|
||||
query += " AND W.start_date <= %s"
|
||||
params.append(max_date)
|
||||
if selected_exercise_ids:
|
||||
placeholders = ", ".join(["%s"] * len(selected_exercise_ids))
|
||||
query += f" AND E.exercise_id IN ({placeholders})"
|
||||
query += f" AND E.exercise_id IN ({', '.join(['%s'] * len(selected_exercise_ids))})"
|
||||
params.extend(selected_exercise_ids)
|
||||
|
||||
# Execute the query
|
||||
topsets = self.execute(query, params)
|
||||
# Execute and convert to DataFrame
|
||||
raw_data = self.execute(query, params)
|
||||
if not raw_data:
|
||||
# Return empty graphs if no data at all
|
||||
return [
|
||||
self.get_graph_model("Workouts per week", {}),
|
||||
self.get_graph_model("PRs per week", {})
|
||||
]
|
||||
|
||||
# Generate graphs
|
||||
weekly_counts = self.get_workout_counts(topsets, 'week')
|
||||
weekly_pr_counts = self.count_prs_over_time(topsets, 'week')
|
||||
df = pd.DataFrame(raw_data)
|
||||
|
||||
graphs = [self.get_weekly_pr_graph_model('Workouts per week', weekly_counts), self.get_weekly_pr_graph_model('PRs per week', weekly_pr_counts)]
|
||||
return graphs
|
||||
# Calculate Estimated1RM in Python
|
||||
df['Estimated1RM'] = df.apply(
|
||||
lambda row: calculate_estimated_1rm(row["Weight"], row["Repetitions"]), axis=1
|
||||
)
|
||||
|
||||
def get_weekly_pr_graph_model(self, 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: {...}, ...}
|
||||
# Build the weekly data models
|
||||
weekly_counts = self.get_workout_counts(df, period='week')
|
||||
weekly_pr_counts = self.count_prs_over_time(df, period='week')
|
||||
|
||||
# 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]
|
||||
return [
|
||||
self.get_graph_model("Workouts per week", weekly_counts),
|
||||
self.get_graph_model("PRs per week", weekly_pr_counts)
|
||||
]
|
||||
|
||||
# Calculate viewBox dimensions
|
||||
max_value = max(max(user_data["PRCounts"].values()) for user_data in weekly_pr_data.values()) or 1
|
||||
min_value = 0
|
||||
value_range = max_value - min_value
|
||||
vb_width = 200
|
||||
vb_height= 75
|
||||
def _prepare_period_column(self, df, period='week'):
|
||||
"""
|
||||
Convert StartDate to datetime and add a Period column
|
||||
based on 'week' or 'month' as needed.
|
||||
"""
|
||||
df['StartDate'] = pd.to_datetime(df['StartDate'], errors='coerce')
|
||||
freq = 'W' if period == 'week' else 'M'
|
||||
df['Period'] = df['StartDate'].dt.to_period(freq)
|
||||
return df
|
||||
|
||||
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"]
|
||||
def get_workout_counts(self, df, period='week'):
|
||||
"""
|
||||
Returns a dictionary:
|
||||
{
|
||||
person_id: {
|
||||
'PersonName': 'Alice',
|
||||
'PRCounts': {
|
||||
Timestamp('2023-01-02'): 2,
|
||||
...
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
representing how many workouts each person performed per time period.
|
||||
"""
|
||||
# Make a copy and prepare Period column
|
||||
df = self._prepare_period_column(df.copy(), period)
|
||||
|
||||
values = pr_counts.values()
|
||||
# Count unique workouts per (PersonId, PersonName, Period)
|
||||
grp = (
|
||||
df.groupby(['PersonId', 'PersonName', 'Period'], as_index=False)['WorkoutId']
|
||||
.nunique()
|
||||
.rename(columns={'WorkoutId': 'Count'})
|
||||
)
|
||||
# Convert each Period to its start time
|
||||
grp['Period'] = grp['Period'].apply(lambda p: p.start_time)
|
||||
|
||||
values_scaled = [((value - min_value) / value_range) * vb_height for value in values]
|
||||
plot_points = list(zip(values_scaled, relative_positions))
|
||||
messages = [f'{value} for {person_name} at {date.strftime("%d %b %y")}' for value, date in zip(values, pr_counts.keys())]
|
||||
plot_labels = zip(values_scaled, relative_positions, messages)
|
||||
return self._pivot_to_graph_dict(
|
||||
grp,
|
||||
index_col='PersonId',
|
||||
name_col='PersonName',
|
||||
period_col='Period',
|
||||
value_col='Count'
|
||||
)
|
||||
|
||||
# Create a plot for each user
|
||||
plot = {
|
||||
'label': person_name, # Use PersonName instead of User ID
|
||||
'color': colors[count],
|
||||
'points': plot_points,
|
||||
'plot_labels': plot_labels
|
||||
def count_prs_over_time(self, df, period='week'):
|
||||
"""
|
||||
Returns a dictionary:
|
||||
{
|
||||
person_id: {
|
||||
'PersonName': 'Alice',
|
||||
'PRCounts': {
|
||||
Timestamp('2023-01-02'): 1,
|
||||
...
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
representing how many PRs each person hit per time period.
|
||||
"""
|
||||
# Make a copy and prepare Period column
|
||||
df = self._prepare_period_column(df.copy(), period)
|
||||
|
||||
# Max 1RM per (Person, Exercise, Period)
|
||||
grouped = (
|
||||
df.groupby(['PersonId', 'PersonName', 'ExerciseId', 'Period'], as_index=False)['Estimated1RM']
|
||||
.max()
|
||||
.rename(columns={'Estimated1RM': 'PeriodMax'})
|
||||
)
|
||||
|
||||
# Sort so we can track "all-time max" up to that row
|
||||
grouped.sort_values(by=['PersonId', 'ExerciseId', 'Period'], inplace=True)
|
||||
|
||||
# For each person & exercise, track the cumulative max (shifted by 1)
|
||||
grouped['AllTimeMax'] = grouped.groupby(['PersonId', 'ExerciseId'])['PeriodMax'].cummax().shift(1)
|
||||
grouped['IsPR'] = (grouped['PeriodMax'] > grouped['AllTimeMax']).astype(int)
|
||||
|
||||
# Sum PRs across exercises for (Person, Period)
|
||||
pr_counts = (
|
||||
grouped.groupby(['PersonId', 'PersonName', 'Period'], as_index=False)['IsPR']
|
||||
.sum()
|
||||
.rename(columns={'IsPR': 'Count'})
|
||||
)
|
||||
pr_counts['Period'] = pr_counts['Period'].apply(lambda p: p.start_time)
|
||||
|
||||
return self._pivot_to_graph_dict(
|
||||
pr_counts,
|
||||
index_col='PersonId',
|
||||
name_col='PersonName',
|
||||
period_col='Period',
|
||||
value_col='Count'
|
||||
)
|
||||
|
||||
def _pivot_to_graph_dict(self, df, index_col, name_col, period_col, value_col):
|
||||
"""
|
||||
Convert [index_col, name_col, period_col, value_col]
|
||||
into a nested dictionary for plotting:
|
||||
{
|
||||
person_id: {
|
||||
'PersonName': <...>,
|
||||
'PRCounts': {
|
||||
<timestamp>: <value>,
|
||||
...
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if df.empty:
|
||||
return {}
|
||||
|
||||
pivoted = df.pivot(
|
||||
index=[index_col, name_col],
|
||||
columns=period_col,
|
||||
values=value_col
|
||||
).fillna(0)
|
||||
|
||||
pivoted.reset_index(inplace=True)
|
||||
|
||||
result = {}
|
||||
for _, row in pivoted.iterrows():
|
||||
pid = row[index_col]
|
||||
pname = row[name_col]
|
||||
# Remaining columns = date -> count
|
||||
period_counts = row.drop([index_col, name_col]).to_dict()
|
||||
result[pid] = {
|
||||
'PersonName': pname,
|
||||
'PRCounts': period_counts
|
||||
}
|
||||
plots.append(plot)
|
||||
|
||||
# Return workout data with SVG dimensions and data points
|
||||
return result
|
||||
|
||||
def get_graph_model(self, title, data_dict):
|
||||
"""
|
||||
Builds a line-graph model from a dictionary of the form:
|
||||
{
|
||||
person_id: {
|
||||
'PersonName': 'Alice',
|
||||
'PRCounts': {
|
||||
Timestamp('2023-01-02'): 2,
|
||||
Timestamp('2023-01-09'): 1,
|
||||
...
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if not data_dict:
|
||||
return {
|
||||
'title': title,
|
||||
'vb_width': 200,
|
||||
'vb_height': 75,
|
||||
'plots': []
|
||||
}
|
||||
|
||||
# Gather all dates & values
|
||||
all_dates = []
|
||||
all_values = []
|
||||
for user_data in data_dict.values():
|
||||
all_dates.extend(user_data['PRCounts'].keys())
|
||||
all_values.extend(user_data['PRCounts'].values())
|
||||
|
||||
min_date = min(all_dates)
|
||||
max_date = max(all_dates)
|
||||
date_span = max((max_date - min_date).days, 1)
|
||||
|
||||
max_val = max(all_values)
|
||||
min_val = 0
|
||||
val_range = max_val - min_val if max_val != min_val else 1
|
||||
|
||||
vb_width, vb_height = 200, 75
|
||||
colors = get_distinct_colors(len(data_dict))
|
||||
plots = []
|
||||
|
||||
for i, (pid, user_data) in enumerate(data_dict.items()):
|
||||
name = user_data['PersonName']
|
||||
pr_counts = user_data['PRCounts']
|
||||
# Sort by date so points are in chronological order
|
||||
sorted_pr = sorted(pr_counts.items(), key=lambda x: x[0])
|
||||
|
||||
points = []
|
||||
labels = []
|
||||
for d, val in sorted_pr:
|
||||
# Scale x,y to fit [0..1], then we multiply y by vb_height
|
||||
x = (d - min_date).days / date_span
|
||||
y = (val - min_val) / val_range * vb_height
|
||||
points.append((y, x))
|
||||
labels.append((y, x, f'{val} for {name} at {d.strftime("%d %b %y")}'))
|
||||
|
||||
plots.append({
|
||||
'label': name,
|
||||
'color': colors[i],
|
||||
'points': points,
|
||||
'plot_labels': labels
|
||||
})
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'vb_width': vb_width,
|
||||
'vb_height': vb_height,
|
||||
'plots': plots
|
||||
}
|
||||
|
||||
def get_workout_counts(self, workouts, period='week'):
|
||||
df = pd.DataFrame(workouts)
|
||||
|
||||
# Convert 'StartDate' to datetime and set period
|
||||
df['StartDate'] = pd.to_datetime(df['StartDate'])
|
||||
df['Period'] = df['StartDate'].dt.to_period('W' if period == 'week' else 'M')
|
||||
|
||||
# Group by PersonId, Period and count unique workouts
|
||||
workout_counts = df.groupby(['PersonId', 'Period'])['WorkoutId'].nunique().reset_index()
|
||||
|
||||
# Convert 'Period' to timestamp using the start date of the period
|
||||
workout_counts['Period'] = workout_counts['Period'].apply(lambda x: x.start_time)
|
||||
|
||||
# Pivot the result to get periods as columns
|
||||
workout_counts_pivot = workout_counts.pivot(index='PersonId', columns='Period', values='WorkoutId').fillna(0)
|
||||
|
||||
# Include person names
|
||||
names = df[['PersonId', 'PersonName']].drop_duplicates().set_index('PersonId')
|
||||
workout_counts_final = names.join(workout_counts_pivot, how='left').fillna(0)
|
||||
|
||||
# Convert DataFrame to dictionary
|
||||
result = workout_counts_final.reset_index().to_dict('records')
|
||||
|
||||
# Reformat the dictionary to desired structure
|
||||
formatted_result = {}
|
||||
for record in result:
|
||||
person_id = record.pop('PersonId')
|
||||
person_name = record.pop('PersonName')
|
||||
pr_counts = {k: v for k, v in record.items()}
|
||||
formatted_result[person_id] = {'PersonName': person_name, 'PRCounts': pr_counts}
|
||||
|
||||
return formatted_result
|
||||
|
||||
def count_prs_over_time(self, workouts, period='week'):
|
||||
df = pd.DataFrame(workouts)
|
||||
|
||||
# Convert 'StartDate' to datetime
|
||||
df['StartDate'] = pd.to_datetime(df['StartDate'])
|
||||
|
||||
# Set period as week or month
|
||||
df['Period'] = df['StartDate'].dt.to_period('W' if period == 'week' else 'M')
|
||||
|
||||
# Group by Person, Exercise, and Period to find max Estimated1RM in each period
|
||||
period_max = df.groupby(['PersonId', 'ExerciseId', 'Period'])['Estimated1RM'].max().reset_index()
|
||||
|
||||
# Determine all-time max Estimated1RM up to the start of each period
|
||||
period_max['AllTimeMax'] = period_max.groupby(['PersonId', 'ExerciseId'])['Estimated1RM'].cummax().shift(1)
|
||||
|
||||
# Identify PRs as entries where the period's max Estimated1RM exceeds the all-time max
|
||||
period_max['IsPR'] = period_max['Estimated1RM'] > period_max['AllTimeMax']
|
||||
|
||||
# Count PRs in each period for each person
|
||||
pr_counts = period_max.groupby(['PersonId', 'Period'])['IsPR'].sum().reset_index()
|
||||
|
||||
# Convert 'Period' to timestamp using the start date of the period
|
||||
pr_counts['Period'] = pr_counts['Period'].apply(lambda x: x.start_time)
|
||||
|
||||
# Pivot table to get the desired output format
|
||||
output = pr_counts.pivot(index='PersonId', columns='Period', values='IsPR').fillna(0)
|
||||
|
||||
# Convert only the PR count columns to integers
|
||||
for col in output.columns:
|
||||
output[col] = output[col].astype(int)
|
||||
|
||||
# Merge with names and convert to desired format
|
||||
names = df[['PersonId', 'PersonName']].drop_duplicates().set_index('PersonId')
|
||||
output = names.join(output, how='left').fillna(0)
|
||||
|
||||
# Reset the index to bring 'PersonId' back as a column
|
||||
output.reset_index(inplace=True)
|
||||
|
||||
# Convert to the final dictionary format with PRCounts nested
|
||||
result = {}
|
||||
for index, row in output.iterrows():
|
||||
person_id = row['PersonId']
|
||||
person_name = row['PersonName']
|
||||
pr_counts = row.drop(['PersonId', 'PersonName']).to_dict()
|
||||
result[person_id] = {"PersonName": person_name, "PRCounts": pr_counts}
|
||||
|
||||
return result
|
||||
@@ -16,4 +16,5 @@ wtforms==3.2.1
|
||||
flask-wtf==1.2.2
|
||||
Flask-Login==0.6.3
|
||||
Flask-Bcrypt==1.0.1
|
||||
email-validator==2.2.0
|
||||
email-validator==2.2.0
|
||||
requests==2.26.0
|
||||
236
routes/export.py
Normal file
236
routes/export.py
Normal file
@@ -0,0 +1,236 @@
|
||||
import csv
|
||||
import io
|
||||
import datetime
|
||||
from flask import Blueprint, Response
|
||||
from extensions import db
|
||||
|
||||
export_bp = Blueprint('export', __name__, url_prefix='/export')
|
||||
|
||||
# --- CSV Export Logic ---
|
||||
|
||||
def _fetch_all_workout_data_for_csv():
|
||||
"""Fetches all workout set data across all users for CSV export."""
|
||||
query = """
|
||||
SELECT
|
||||
p.name AS person_name,
|
||||
w.start_date,
|
||||
e.name AS exercise_name,
|
||||
t.repetitions,
|
||||
t.weight,
|
||||
w.note AS workout_note
|
||||
FROM
|
||||
topset t
|
||||
JOIN
|
||||
workout w ON t.workout_id = w.workout_id
|
||||
JOIN
|
||||
person p ON w.person_id = p.person_id
|
||||
JOIN
|
||||
exercise e ON t.exercise_id = e.exercise_id
|
||||
ORDER BY
|
||||
p.name,
|
||||
w.start_date,
|
||||
t.topset_id;
|
||||
"""
|
||||
return db.execute(query)
|
||||
|
||||
@export_bp.route('/workouts.csv')
|
||||
def export_workouts_csv():
|
||||
"""Generates and returns a CSV file of all workout sets."""
|
||||
data = _fetch_all_workout_data_for_csv()
|
||||
|
||||
if not data:
|
||||
return Response("", mimetype='text/csv', headers={"Content-disposition": "attachment; filename=workout_export_empty.csv"})
|
||||
|
||||
si = io.StringIO()
|
||||
fieldnames = ['person_name', 'start_date', 'exercise_name', 'repetitions', 'weight', 'workout_note']
|
||||
writer = csv.DictWriter(si, fieldnames=fieldnames, quoting=csv.QUOTE_ALL) # Quote all fields for safety
|
||||
|
||||
writer.writeheader()
|
||||
# Format date objects to strings for CSV
|
||||
formatted_data = []
|
||||
for row in data:
|
||||
new_row = row.copy()
|
||||
if isinstance(new_row.get('start_date'), (datetime.date, datetime.datetime)):
|
||||
new_row['start_date'] = new_row['start_date'].isoformat()
|
||||
formatted_data.append(new_row)
|
||||
|
||||
writer.writerows(formatted_data)
|
||||
|
||||
output = si.getvalue()
|
||||
return Response(
|
||||
output,
|
||||
mimetype='text/csv',
|
||||
headers={"Content-disposition": "attachment; filename=workout_export.csv"}
|
||||
)
|
||||
|
||||
# --- SQL Export Logic ---
|
||||
|
||||
# Helper functions adapted from sql_explorer
|
||||
def _get_schema_info(schema='public'):
|
||||
"""Fetches schema information directly."""
|
||||
tables_result = db.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_type = 'BASE TABLE';
|
||||
""", [schema])
|
||||
tables = [row['table_name'] for row in tables_result]
|
||||
|
||||
schema_info = {}
|
||||
for table in tables:
|
||||
columns_result = db.execute("""
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %s AND table_name = %s
|
||||
ORDER BY ordinal_position;
|
||||
""", [schema, table])
|
||||
columns = [(row['column_name'], row['data_type']) for row in columns_result]
|
||||
|
||||
primary_keys_result = db.execute("""
|
||||
SELECT kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = %s AND tc.table_name = %s;
|
||||
""", [schema, table])
|
||||
primary_keys = [row['column_name'] for row in primary_keys_result]
|
||||
|
||||
foreign_keys_result = db.execute("""
|
||||
SELECT kcu.column_name AS fk_column, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column
|
||||
FROM information_schema.table_constraints AS tc
|
||||
JOIN information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage AS ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s AND tc.table_name = %s;
|
||||
""", [schema, table])
|
||||
foreign_keys = [(row['fk_column'], row['referenced_table'], row['referenced_column']) for row in foreign_keys_result]
|
||||
|
||||
schema_info[table] = {
|
||||
'columns': columns,
|
||||
'primary_keys': primary_keys,
|
||||
'foreign_keys': foreign_keys
|
||||
}
|
||||
return schema_info
|
||||
|
||||
def _map_data_type_for_sql(postgres_type):
|
||||
"""Maps PostgreSQL types to standard SQL types (simplified)."""
|
||||
return {
|
||||
'character varying': 'VARCHAR', 'varchar': 'VARCHAR', 'text': 'TEXT',
|
||||
'integer': 'INTEGER', 'bigint': 'BIGINT', 'boolean': 'BOOLEAN',
|
||||
'timestamp without time zone': 'TIMESTAMP', 'timestamp with time zone': 'TIMESTAMPTZ',
|
||||
'numeric': 'NUMERIC', 'real': 'REAL', 'date': 'DATE'
|
||||
}.get(postgres_type, postgres_type.upper())
|
||||
|
||||
def _generate_create_script(schema_info):
|
||||
"""Generates SQL CREATE TABLE scripts from schema info."""
|
||||
lines = []
|
||||
for table, info in schema_info.items():
|
||||
columns = info['columns']
|
||||
pks = info.get('primary_keys', [])
|
||||
fks = info['foreign_keys']
|
||||
column_defs = []
|
||||
for column_name, data_type in columns:
|
||||
sql_type = _map_data_type_for_sql(data_type)
|
||||
# Ensure column names are quoted if they might be keywords or contain special chars
|
||||
column_defs.append(f' "{column_name}" {sql_type}')
|
||||
if pks:
|
||||
pk_columns = ", ".join(f'"{pk}"' for pk in pks)
|
||||
column_defs.append(f' PRIMARY KEY ({pk_columns})')
|
||||
|
||||
columns_sql = ",\n".join(column_defs)
|
||||
# Ensure table names are quoted
|
||||
create_stmt = f'CREATE TABLE "{table}" (\n{columns_sql}\n);'
|
||||
lines.append(create_stmt)
|
||||
|
||||
# Add FK constraints separately for clarity and potential circular dependencies
|
||||
for fk_column, ref_table, ref_col in fks:
|
||||
alter_stmt = (
|
||||
f'ALTER TABLE "{table}" ADD CONSTRAINT "fk_{table}_{fk_column}" '
|
||||
f'FOREIGN KEY ("{fk_column}") REFERENCES "{ref_table}" ("{ref_col}");'
|
||||
)
|
||||
lines.append(alter_stmt)
|
||||
lines.append("\n-- ----------------------------\n") # Separator
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_sql_value(value):
|
||||
"""Formats Python values for SQL INSERT statements."""
|
||||
if value is None:
|
||||
return "NULL"
|
||||
elif isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
elif isinstance(value, bool):
|
||||
return "TRUE" if value else "FALSE"
|
||||
elif isinstance(value, (datetime.date, datetime.datetime)):
|
||||
# Format dates/timestamps in ISO 8601 format, suitable for PostgreSQL
|
||||
return f"'{value.isoformat()}'"
|
||||
else:
|
||||
# Assume string, escape single quotes and use concatenation
|
||||
escaped_value = str(value).replace("'", "''")
|
||||
return "'" + escaped_value + "'"
|
||||
|
||||
def _fetch_and_format_data_for_sql_insert():
|
||||
"""Fetches data from all tables and formats it as SQL INSERT statements."""
|
||||
# Define the order of tables to handle potential FK constraints during insert
|
||||
# (e.g., insert persons before workouts)
|
||||
table_order = ['person', 'exercise', 'tag', 'workout', 'topset', 'workout_tag', 'saved_query']
|
||||
all_insert_statements = []
|
||||
|
||||
for table_name in table_order:
|
||||
all_insert_statements.append(f"\n-- Data for table: {table_name}\n")
|
||||
try:
|
||||
# Fetch all data from the table
|
||||
# Using db.execute which returns list of dicts
|
||||
rows = db.execute(f'SELECT * FROM "{table_name}"') # Quote table name
|
||||
|
||||
if not rows:
|
||||
all_insert_statements.append(f"-- No data found for table {table_name}.\n")
|
||||
continue
|
||||
|
||||
# Get column names from the first row (keys of the dict)
|
||||
# Ensure column names are quoted
|
||||
column_names = [f'"{col}"' for col in rows[0].keys()]
|
||||
columns_sql = ", ".join(column_names)
|
||||
|
||||
# Generate INSERT statement for each row
|
||||
for row in rows:
|
||||
values = [_format_sql_value(row[col.strip('"')]) for col in column_names] # Use unquoted keys to access dict
|
||||
values_sql = ", ".join(values)
|
||||
insert_stmt = f'INSERT INTO "{table_name}" ({columns_sql}) VALUES ({values_sql});'
|
||||
all_insert_statements.append(insert_stmt)
|
||||
|
||||
except Exception as e:
|
||||
# Log error or add a comment to the script
|
||||
all_insert_statements.append(f"-- Error fetching/formatting data for table {table_name}: {e}\n")
|
||||
|
||||
return "\n".join(all_insert_statements)
|
||||
|
||||
|
||||
@export_bp.route('/database.sql')
|
||||
def export_database_sql():
|
||||
"""Generates and returns a .sql file with schema and data."""
|
||||
try:
|
||||
# Generate Schema
|
||||
schema_info = _get_schema_info()
|
||||
create_script = _generate_create_script(schema_info)
|
||||
|
||||
# Generate Data Inserts
|
||||
insert_script = _fetch_and_format_data_for_sql_insert()
|
||||
|
||||
# Combine scripts
|
||||
full_script = f"-- WorkoutTracker Database Export\n"
|
||||
full_script += f"-- Generated on: {datetime.datetime.now().isoformat()}\n\n"
|
||||
full_script += "-- Schema Definition --\n"
|
||||
full_script += create_script
|
||||
full_script += "\n-- Data Inserts --\n"
|
||||
full_script += insert_script
|
||||
|
||||
return Response(
|
||||
full_script,
|
||||
mimetype='application/sql',
|
||||
headers={"Content-disposition":
|
||||
"attachment; filename=workout_tracker_export.sql"}
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error properly in a real application
|
||||
print(f"Error generating SQL export: {e}")
|
||||
return Response(f"-- Error generating SQL export: {e}", status=500, mimetype='text/plain')
|
||||
271
routes/programs.py
Normal file
271
routes/programs.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, current_app
|
||||
from extensions import db
|
||||
# from flask_login import login_required, current_user # Add if authentication is needed
|
||||
from jinja2_fragments import render_block # Import render_block
|
||||
|
||||
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
|
||||
|
||||
from flask import flash # Import flash for displaying messages
|
||||
|
||||
@programs_bp.route('/create', methods=['GET', 'POST'])
|
||||
# @login_required # Uncomment if login is required
|
||||
def create_program():
|
||||
if request.method == 'POST':
|
||||
program_name = request.form.get('program_name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
sessions_data = []
|
||||
i = 0
|
||||
while True:
|
||||
# Check for the presence of session order to determine if the session exists
|
||||
session_order_key = f'session_order_{i}'
|
||||
if session_order_key not in request.form:
|
||||
break # No more sessions
|
||||
|
||||
session_order = request.form.get(session_order_key)
|
||||
session_name = request.form.get(f'session_name_{i}', '').strip()
|
||||
# Get list of selected exercise IDs for this session
|
||||
exercise_ids_str = request.form.getlist(f'exercises_{i}')
|
||||
|
||||
# Basic validation for session data
|
||||
if not exercise_ids_str or not session_order:
|
||||
flash(f"Error processing session {i+1}: Missing exercises or order.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
try:
|
||||
# Convert exercise IDs to integers and sort them for consistent filter generation
|
||||
exercise_ids = sorted([int(eid) for eid in exercise_ids_str])
|
||||
|
||||
sessions_data.append({
|
||||
'order': int(session_order),
|
||||
'name': session_name if session_name else None, # Store None if empty
|
||||
'exercise_ids': exercise_ids # Store the list of exercise IDs
|
||||
})
|
||||
except ValueError:
|
||||
flash(f"Error processing session {i+1}: Invalid exercise ID or order.", "error")
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
i += 1
|
||||
|
||||
# --- Validation ---
|
||||
if not program_name:
|
||||
flash("Program Name is required.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
if not sessions_data:
|
||||
flash("At least one session must be added.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
# --- Database Insertion ---
|
||||
try:
|
||||
# Insert Program
|
||||
program_result = db.execute(
|
||||
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
|
||||
[program_name, description if description else None],
|
||||
commit=True, one=True
|
||||
)
|
||||
if not program_result or 'program_id' not in program_result:
|
||||
raise Exception("Failed to create workout program entry.")
|
||||
|
||||
new_program_id = program_result['program_id']
|
||||
|
||||
# Insert Sessions (and find/create tags)
|
||||
for session in sessions_data:
|
||||
# 1. Generate the canonical filter string from sorted exercise IDs
|
||||
if not session['exercise_ids']:
|
||||
flash(f"Session {session['order']} must have at least one exercise selected.", "error")
|
||||
# Ideally, rollback program insert or handle differently
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in session['exercise_ids'])
|
||||
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises" # Default tag name
|
||||
|
||||
# 2. Find existing tag with this exact filter (non-person specific)
|
||||
existing_tag = db.execute(
|
||||
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
||||
[tag_filter], one=True
|
||||
)
|
||||
|
||||
session_tag_id = None
|
||||
if existing_tag:
|
||||
session_tag_id = existing_tag['tag_id']
|
||||
# Optional: Update tag name if session name provided and different?
|
||||
# db.execute("UPDATE tag SET name = %s WHERE tag_id = %s", [tag_name, session_tag_id], commit=True)
|
||||
else:
|
||||
# 3. Create new tag if not found
|
||||
# Ensure tag name uniqueness if desired (e.g., append number if name exists)
|
||||
# For simplicity, allow duplicate names for now, rely on filter for uniqueness
|
||||
new_tag_result = db.execute(
|
||||
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
||||
[tag_name, tag_filter], commit=True, one=True
|
||||
)
|
||||
if not new_tag_result or 'tag_id' not in new_tag_result:
|
||||
raise Exception(f"Failed to create tag for session {session['order']}.")
|
||||
session_tag_id = new_tag_result['tag_id']
|
||||
|
||||
# 4. Insert program_session using the found/created tag_id
|
||||
db.execute(
|
||||
"""INSERT INTO program_session (program_id, session_order, session_name, tag_id)
|
||||
VALUES (%s, %s, %s, %s)""",
|
||||
[new_program_id, session['order'], session['name'], session_tag_id],
|
||||
commit=True # Commit each session insert
|
||||
)
|
||||
|
||||
flash(f"Workout Program '{program_name}' created successfully!", "success")
|
||||
# TODO: Redirect to a program view page once it exists
|
||||
# return redirect(url_for('programs.view_program', program_id=new_program_id))
|
||||
return redirect(url_for('programs.list_programs')) # Redirect to a list page for now
|
||||
|
||||
except Exception as e:
|
||||
# Log the error e
|
||||
print(f"Error creating program: {e}") # Basic logging
|
||||
flash(f"Database error creating program: {e}", "error")
|
||||
# Rollback might be needed if using transactions across inserts
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
else: # GET Request
|
||||
# Fetch all available exercises to populate multi-selects
|
||||
exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
|
||||
if exercises is None:
|
||||
exercises = [] # Ensure exercises is an iterable
|
||||
|
||||
# Pass exercises to the template context
|
||||
return render_template('program_create.html', exercises=exercises, render_block=render_block) # Pass exercises instead of tags
|
||||
|
||||
|
||||
from flask_htmx import HTMX # Import HTMX
|
||||
|
||||
htmx = HTMX() # Initialize HTMX if not already done globally
|
||||
|
||||
# Placeholder for program list route (used in POST redirect)
|
||||
@programs_bp.route('/', methods=['GET'])
|
||||
# @login_required
|
||||
def list_programs():
|
||||
# Fetch and display list of programs
|
||||
programs = db.execute("SELECT program_id, name, description FROM workout_program ORDER BY created_at DESC")
|
||||
if programs is None:
|
||||
programs = []
|
||||
|
||||
# Check if it's an HTMX request
|
||||
if htmx:
|
||||
# Render only the content block for HTMX requests
|
||||
return render_block(current_app.jinja_env, 'program_list.html', 'content', programs=programs)
|
||||
else:
|
||||
# Render the full page for regular requests
|
||||
return render_template('program_list.html', programs=programs)
|
||||
|
||||
|
||||
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
|
||||
# @login_required # Add authentication if needed
|
||||
def delete_program(program_id):
|
||||
"""Deletes a workout program and its associated sessions/assignments."""
|
||||
try:
|
||||
# The ON DELETE CASCADE constraint on program_session and person_program_assignment
|
||||
# should handle deleting related rows automatically when the program is deleted.
|
||||
result = db.execute(
|
||||
"DELETE FROM workout_program WHERE program_id = %s RETURNING program_id",
|
||||
[program_id],
|
||||
commit=True, one=True
|
||||
)
|
||||
if result and result.get('program_id') == program_id:
|
||||
# Return empty response for HTMX, maybe trigger list refresh
|
||||
# flash(f"Program ID {program_id} deleted successfully.", "success") # Flash might not show on empty response
|
||||
response = "" # Empty response indicates success to HTMX
|
||||
headers = {"HX-Trigger": "programDeleted"} # Trigger event for potential list refresh
|
||||
return response, 200, headers
|
||||
else:
|
||||
# Program not found or delete failed silently
|
||||
flash(f"Could not find or delete program ID {program_id}.", "error")
|
||||
# Returning an error status might be better for HTMX error handling
|
||||
return "Error: Program not found or deletion failed", 404
|
||||
|
||||
except Exception as e:
|
||||
# Log the error e
|
||||
print(f"Error deleting program {program_id}: {e}")
|
||||
flash(f"Database error deleting program: {e}", "error")
|
||||
# Return an error status for HTMX
|
||||
return "Server error during deletion", 500
|
||||
|
||||
|
||||
# TODO: Add routes for viewing, editing, and assigning programs
|
||||
from urllib.parse import parse_qs # Needed to parse tag filters
|
||||
|
||||
@programs_bp.route('/<int:program_id>', methods=['GET'])
|
||||
# @login_required
|
||||
def view_program(program_id):
|
||||
"""Displays the details of a specific workout program."""
|
||||
# Fetch program details
|
||||
program = db.execute(
|
||||
"SELECT program_id, name, description, created_at FROM workout_program WHERE program_id = %s",
|
||||
[program_id], one=True
|
||||
)
|
||||
if not program:
|
||||
flash(f"Workout Program with ID {program_id} not found.", "error")
|
||||
return redirect(url_for('programs.list_programs'))
|
||||
|
||||
# Fetch sessions and their associated tags
|
||||
sessions = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
ps.session_id, ps.session_order, ps.session_name,
|
||||
t.tag_id, t.name as tag_name, t.filter as tag_filter
|
||||
FROM program_session ps
|
||||
JOIN tag t ON ps.tag_id = t.tag_id
|
||||
WHERE ps.program_id = %s
|
||||
ORDER BY ps.session_order ASC
|
||||
""",
|
||||
[program_id]
|
||||
)
|
||||
|
||||
# Process sessions to extract exercise IDs and fetch exercise names
|
||||
sessions_with_exercises = []
|
||||
if sessions:
|
||||
for session in sessions:
|
||||
exercise_ids = []
|
||||
if session.get('tag_filter'):
|
||||
# Parse the filter string (e.g., "?exercise_id=5&exercise_id=1009")
|
||||
parsed_filter = parse_qs(session['tag_filter'].lstrip('?'))
|
||||
exercise_ids_str = parsed_filter.get('exercise_id', [])
|
||||
try:
|
||||
# Ensure IDs are unique and sorted if needed, though order might matter from filter
|
||||
exercise_ids = sorted(list(set(int(eid) for eid in exercise_ids_str)))
|
||||
except ValueError:
|
||||
print(f"Warning: Could not parse exercise IDs from filter for tag {session['tag_id']}: {session['tag_filter']}")
|
||||
exercise_ids = [] # Handle parsing error gracefully
|
||||
|
||||
exercises = []
|
||||
if exercise_ids:
|
||||
# Fetch exercise details for the extracted IDs
|
||||
# Using tuple() for IN clause compatibility
|
||||
# Ensure tuple has at least one element for SQL IN clause
|
||||
if len(exercise_ids) == 1:
|
||||
exercises_tuple = (exercise_ids[0],) # Comma makes it a tuple
|
||||
else:
|
||||
exercises_tuple = tuple(exercise_ids)
|
||||
|
||||
exercises = db.execute(
|
||||
"SELECT exercise_id, name FROM exercise WHERE exercise_id IN %s ORDER BY name",
|
||||
[exercises_tuple]
|
||||
)
|
||||
if exercises is None: exercises = [] # Ensure it's iterable
|
||||
|
||||
sessions_with_exercises.append({
|
||||
**session, # Include all original session/tag data
|
||||
'exercises': exercises
|
||||
})
|
||||
|
||||
# Prepare context for the template
|
||||
context = {
|
||||
'program': program,
|
||||
'sessions': sessions_with_exercises
|
||||
}
|
||||
|
||||
# Check for HTMX request (optional, for potential future use)
|
||||
if htmx:
|
||||
# Assuming you have a block named 'content' in program_view.html
|
||||
return render_block(current_app.jinja_env, 'program_view.html', 'content', **context)
|
||||
else:
|
||||
return render_template('program_view.html', **context)
|
||||
|
||||
# TODO: Add routes for editing and assigning programs
|
||||
@@ -5,7 +5,7 @@ from flask import Blueprint, render_template, request, current_app, jsonify
|
||||
from jinja2_fragments import render_block
|
||||
from flask_htmx import HTMX
|
||||
from extensions import db
|
||||
from utils import generate_plot
|
||||
from utils import prepare_svg_plot_data # Will be created for SVG data prep
|
||||
|
||||
sql_explorer_bp = Blueprint('sql_explorer', __name__, url_prefix='/sql')
|
||||
htmx = HTMX()
|
||||
@@ -216,7 +216,12 @@ Return ONLY the SQL query, without any explanation or surrounding text/markdown.
|
||||
if generated_sql.endswith("```"):
|
||||
generated_sql = generated_sql[:-3]
|
||||
|
||||
return generated_sql.strip(), None
|
||||
# Remove leading SQL comment lines
|
||||
sql_lines = generated_sql.strip().splitlines()
|
||||
filtered_lines = [line for line in sql_lines if not line.strip().startswith('--')]
|
||||
final_sql = "\n".join(filtered_lines).strip()
|
||||
|
||||
return final_sql, None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
current_app.logger.error(f"Gemini API request error: {e}")
|
||||
@@ -276,17 +281,47 @@ def sql_schema():
|
||||
def plot_query(query_id):
|
||||
(title, query) = _get_saved_query(query_id)
|
||||
if not query: return "Query not found", 404
|
||||
results_df = db.read_sql_as_df(query)
|
||||
plot_div = generate_plot(results_df, title)
|
||||
return plot_div
|
||||
# Fetch raw results instead of DataFrame
|
||||
(results, columns, error) = _execute_sql(query)
|
||||
if error:
|
||||
# Return an HTML snippet indicating the error
|
||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error executing query: {error}</div>', 400
|
||||
if not results:
|
||||
# Return an HTML snippet indicating no data
|
||||
return '<div class="p-4 text-yellow-700 bg-yellow-100 border border-yellow-400 rounded">No data returned by query.</div>'
|
||||
|
||||
try:
|
||||
# Prepare data for SVG plotting (function to be created in utils.py)
|
||||
plot_data = prepare_svg_plot_data(results, columns, title)
|
||||
# Render the new SVG template
|
||||
return render_template('partials/sql_explorer/svg_plot.html', **plot_data)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error preparing SVG plot data: {e}")
|
||||
# Return an HTML snippet indicating a processing error
|
||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
||||
|
||||
@sql_explorer_bp.route("/plot/show", methods=['POST'])
|
||||
def plot_unsaved_query():
|
||||
query = request.form.get('query')
|
||||
title = request.form.get('title')
|
||||
results_df = db.read_sql_as_df(query)
|
||||
plot_div = generate_plot(results_df, title)
|
||||
return plot_div
|
||||
title = request.form.get('title', 'SQL Query Plot') # Add default title
|
||||
# Fetch raw results instead of DataFrame
|
||||
(results, columns, error) = _execute_sql(query)
|
||||
if error:
|
||||
# Return an HTML snippet indicating the error
|
||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error executing query: {error}</div>', 400
|
||||
if not results:
|
||||
# Return an HTML snippet indicating no data
|
||||
return '<div class="p-4 text-yellow-700 bg-yellow-100 border border-yellow-400 rounded">No data returned by query.</div>'
|
||||
|
||||
try:
|
||||
# Prepare data for SVG plotting (function to be created in utils.py)
|
||||
plot_data = prepare_svg_plot_data(results, columns, title)
|
||||
# Render the new SVG template
|
||||
return render_template('partials/sql_explorer/svg_plot.html', **plot_data)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error preparing SVG plot data: {e}")
|
||||
# Return an HTML snippet indicating a processing error
|
||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
||||
|
||||
@sql_explorer_bp.route("/generate_sql", methods=['POST'])
|
||||
def generate_sql():
|
||||
|
||||
263
routes/tags.py
Normal file
263
routes/tags.py
Normal file
@@ -0,0 +1,263 @@
|
||||
from flask import Blueprint, request, redirect, url_for, render_template, current_app
|
||||
from urllib.parse import urlencode, parse_qs, unquote_plus
|
||||
from flask_login import current_user
|
||||
from extensions import db
|
||||
from jinja2_fragments import render_block
|
||||
|
||||
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
|
||||
|
||||
# Helper function to get tags (assuming similar logic exists in dashboard/person_overview)
|
||||
# NOTE: This is a placeholder and might need adjustment based on actual data fetching logic
|
||||
def _get_tags_data(person_id=None):
|
||||
if person_id:
|
||||
# Logic to fetch tags for a specific person
|
||||
tags_raw = db.get_tags_for_person(person_id)
|
||||
# Assuming get_tags_for_person returns list like [{'tag_id': 1, 'tag_name': 'Bulk', 'tag_filter': '?tag=Bulk'}]
|
||||
return tags_raw # Adjust based on actual return format
|
||||
else:
|
||||
tags_raw = db.get_tags_for_dashboard()
|
||||
return tags_raw
|
||||
|
||||
|
||||
@tags_bp.route("/redirect", methods=['GET'])
|
||||
def goto_tag():
|
||||
"""Redirects or loads content based on tag filter."""
|
||||
person_id = request.args.get("person_id")
|
||||
tag_filter = request.args.get("filter", "") # Default to empty string
|
||||
|
||||
if person_id:
|
||||
# Assuming person_overview handles HTMX requests to render blocks
|
||||
# Corrected endpoint name for person overview
|
||||
target_url = url_for('person_overview', person_id=person_id) + tag_filter
|
||||
# Check if it's an HTMX request targeting #container
|
||||
if request.headers.get('HX-Target') == 'container' or request.headers.get('HX-Target') == '#container':
|
||||
# Need the actual function that renders person_overview content block
|
||||
# Placeholder: Re-render the person overview block with the filter
|
||||
# This requires knowing how person_overview fetches its data based on filters
|
||||
# return render_block('person_overview.html', 'content_block', person_id=person_id, filter=tag_filter)
|
||||
# For now, let's assume a full redirect might be simpler if block rendering is complex
|
||||
return redirect(target_url)
|
||||
else:
|
||||
return redirect(target_url)
|
||||
else:
|
||||
# Assuming dashboard handles HTMX requests to render blocks
|
||||
target_url = url_for('dashboard') + tag_filter
|
||||
if request.headers.get('HX-Target') == 'container' or request.headers.get('HX-Target') == '#container':
|
||||
# Need the actual function that renders dashboard content block
|
||||
# Placeholder: Re-render the dashboard block with the filter
|
||||
# This requires knowing how dashboard fetches its data based on filters
|
||||
# return render_block('dashboard.html', 'content_block', filter=tag_filter)
|
||||
# For now, let's assume a full redirect might be simpler
|
||||
return redirect(target_url)
|
||||
else:
|
||||
return redirect(target_url)
|
||||
|
||||
|
||||
@tags_bp.route("/add", methods=['POST']) # Changed to POST
|
||||
def add_tag():
|
||||
"""Adds a tag and returns the updated tags partial."""
|
||||
person_id = request.form.get("person_id") # Get from form data
|
||||
tag_name = request.form.get('tag_name')
|
||||
current_filter_str = request.form.get('current_filter', '')
|
||||
|
||||
if not tag_name:
|
||||
# Handle error - maybe return an error message partial?
|
||||
# For now, just re-render tags without adding
|
||||
tags = _get_tags_data(person_id)
|
||||
return render_template('partials/tags.html', tags=tags, person_id=person_id)
|
||||
|
||||
|
||||
# Parse the current filter string, add the new tag, and re-encode
|
||||
parsed_params = parse_qs(current_filter_str)
|
||||
# parse_qs returns lists for values, handle potential existing 'tag' param
|
||||
parsed_params['tag'] = [tag_name] # Set/overwrite tag param with the new one
|
||||
# Re-encode, ensuring proper handling of multiple values if needed (though 'tag' is likely single)
|
||||
tag_filter_value = "?" + urlencode(parsed_params, doseq=True)
|
||||
|
||||
if person_id:
|
||||
db.add_or_update_tag_for_person(person_id, tag_name, tag_filter_value)
|
||||
else:
|
||||
db.add_or_update_tag_for_dashboard(tag_name, tag_filter_value)
|
||||
|
||||
# Fetch updated tags and render the partial
|
||||
tags = _get_tags_data(person_id)
|
||||
return render_template('partials/tags.html', tags=tags, person_id=person_id)
|
||||
|
||||
|
||||
@tags_bp.route("/<int:tag_id>/delete", methods=['DELETE']) # Changed to DELETE
|
||||
def delete_tag(tag_id):
|
||||
"""Deletes a tag and returns the updated tags partial."""
|
||||
# We might get person_id from request body/headers if needed, or assume context
|
||||
# For simplicity, let's try deleting based on tag_id only first, assuming tags are unique enough or context is handled elsewhere
|
||||
# If person_id is strictly required for deletion scope:
|
||||
person_id = request.form.get("person_id") # Or from headers/session
|
||||
|
||||
# Decide which delete function to call based on context (person page vs dashboard)
|
||||
if person_id:
|
||||
db.delete_tag_for_person(person_id=person_id, tag_id=tag_id)
|
||||
else:
|
||||
db.delete_tag_for_dashboard(tag_id=tag_id)
|
||||
|
||||
# Fetch updated tags and render the partial
|
||||
tags = _get_tags_data(person_id)
|
||||
return render_template('partials/tags.html', tags=tags, person_id=person_id)
|
||||
|
||||
# --- Workout Specific Tag Routes ---
|
||||
|
||||
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
|
||||
def add_tag_to_workout(workout_id):
|
||||
"""Adds existing tags to a specific workout."""
|
||||
# Note: Authorization (checking if the current user can modify this workout) might be needed here.
|
||||
tags_id = [int(i) for i in request.form.getlist('tag_id')]
|
||||
|
||||
# --- Start: DB logic from db.add_tag_for_workout ---
|
||||
# If tags_id is not empty, delete tags that are not in the new selection
|
||||
if tags_id:
|
||||
db.execute(
|
||||
"""
|
||||
DELETE FROM workout_tag
|
||||
WHERE workout_id = %s AND tag_id NOT IN %s
|
||||
""",
|
||||
[workout_id, tuple(tags_id)], commit=True
|
||||
)
|
||||
else:
|
||||
# If tags_id is empty, delete all tags for this workout
|
||||
db.execute(
|
||||
"""
|
||||
DELETE FROM workout_tag
|
||||
WHERE workout_id = %s
|
||||
""",
|
||||
[workout_id], commit=True
|
||||
)
|
||||
|
||||
# Then, attempt to insert the new tags
|
||||
for tag_id in tags_id:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO workout_tag (workout_id, tag_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (workout_id, tag_id) DO NOTHING
|
||||
""",
|
||||
[workout_id, tag_id], commit=True
|
||||
)
|
||||
|
||||
# Now fetch updated list of workout tags
|
||||
workout_tags = db.execute("""
|
||||
SELECT
|
||||
T.tag_id AS "tag_id",
|
||||
T.person_id AS "person_id",
|
||||
T.name AS "tag_name",
|
||||
T.filter AS "tag_filter",
|
||||
TRUE AS "is_selected" -- Mark these as selected since they are now associated
|
||||
FROM Workout_Tag WT
|
||||
LEFT JOIN Tag T ON WT.tag_id=T.tag_id
|
||||
WHERE WT.workout_id=%s
|
||||
ORDER BY T.name ASC -- Keep consistent ordering
|
||||
""", [workout_id])
|
||||
# --- End: DB logic from db.add_tag_for_workout ---
|
||||
|
||||
# We need person_id for rendering the partial if it requires it, fetch it.
|
||||
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
||||
person_id = workout_info['person_id'] if workout_info else None
|
||||
|
||||
# The partial likely needs the full tag list for the workout, including unselected ones.
|
||||
# Let's fetch all relevant tags for the person and mark selected ones.
|
||||
all_person_tags = db.execute("""
|
||||
SELECT
|
||||
tag.tag_id,
|
||||
tag.name AS tag_name,
|
||||
tag.filter as tag_filter,
|
||||
EXISTS (
|
||||
SELECT 1 FROM workout_tag wt_check
|
||||
WHERE wt_check.workout_id = %s AND wt_check.tag_id = tag.tag_id
|
||||
) AS is_selected
|
||||
FROM tag
|
||||
WHERE tag.person_id = %s OR tag.person_id IS NULL -- Include global tags
|
||||
ORDER BY tag.name ASC;
|
||||
""", [workout_id, person_id])
|
||||
|
||||
|
||||
# Render the partial with the complete list of tags (selected and unselected)
|
||||
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
|
||||
|
||||
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
|
||||
def create_new_tag_for_workout(workout_id):
|
||||
"""Creates a new tag and associates it with a specific workout."""
|
||||
# Note: Authorization might be needed here.
|
||||
tag_name = request.form.get('tag_name')
|
||||
if not tag_name:
|
||||
# Handle error: Tag name cannot be empty
|
||||
# Consider returning an error message via HTMX
|
||||
return "Tag name cannot be empty", 400
|
||||
|
||||
# Fetch person_id associated with the workout_id first
|
||||
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
||||
if not workout_info:
|
||||
# Handle error: Workout not found
|
||||
return "Workout not found", 404
|
||||
person_id = workout_info['person_id']
|
||||
|
||||
# --- Start: DB logic from db.create_tag_for_workout ---
|
||||
# Determine tag filter based on exercises in the workout (if any)
|
||||
workout_exercises = db.execute("""
|
||||
SELECT DISTINCT E.exercise_id AS "exercise_id"
|
||||
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.workout_id=%s AND E.exercise_id IS NOT NULL""", [workout_id])
|
||||
|
||||
tag_filter = ""
|
||||
if workout_exercises:
|
||||
tag_filter = "?" + "&".join(
|
||||
f"exercise_id={e['exercise_id']}" for e in workout_exercises)
|
||||
|
||||
# Create tag for person (or update if exists with same name for this person)
|
||||
# Check if tag already exists for this person
|
||||
existing_tag = db.execute(
|
||||
"SELECT tag_id FROM Tag WHERE person_id = %s AND name = %s",
|
||||
[person_id, tag_name], one=True
|
||||
)
|
||||
|
||||
new_tag_id = None
|
||||
if existing_tag:
|
||||
new_tag_id = existing_tag['tag_id']
|
||||
# Optionally update the filter if it's different? For now, just use existing tag.
|
||||
# db.execute("UPDATE Tag SET filter = %s WHERE tag_id = %s", [tag_filter, new_tag_id], commit=True)
|
||||
else:
|
||||
# Create the new tag
|
||||
row = db.execute(
|
||||
'INSERT INTO Tag (person_id, name, filter) VALUES (%s, %s, %s) RETURNING tag_id AS "tag_id"',
|
||||
[person_id, tag_name, tag_filter], commit=True, one=True
|
||||
)
|
||||
if row:
|
||||
new_tag_id = row['tag_id']
|
||||
|
||||
if not new_tag_id:
|
||||
# Handle error: Failed to create or find tag
|
||||
return "Error creating tag", 500
|
||||
|
||||
# Add tag to workout (handle conflict just in case)
|
||||
db.execute(
|
||||
'INSERT INTO Workout_Tag (workout_id, tag_id) VALUES (%s, %s) ON CONFLICT (workout_id, tag_id) DO NOTHING',
|
||||
[workout_id, new_tag_id], commit=True
|
||||
)
|
||||
|
||||
# Now fetch updated list of all workout tags (selected and unselected)
|
||||
all_person_tags = db.execute("""
|
||||
SELECT
|
||||
tag.tag_id,
|
||||
tag.name AS tag_name,
|
||||
tag.filter as tag_filter,
|
||||
EXISTS (
|
||||
SELECT 1 FROM workout_tag wt_check
|
||||
WHERE wt_check.workout_id = %s AND wt_check.tag_id = tag.tag_id
|
||||
) AS is_selected
|
||||
FROM tag
|
||||
WHERE tag.person_id = %s OR tag.person_id IS NULL -- Include global tags
|
||||
ORDER BY tag.name ASC;
|
||||
""", [workout_id, person_id])
|
||||
# --- End: DB logic from db.create_tag_for_workout ---
|
||||
|
||||
# Render the partial with the complete list of tags
|
||||
# Use 'tags' as the variable name consistent with the other endpoint and likely the partial
|
||||
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
|
||||
@@ -208,18 +208,6 @@ def delete_topset(person_id, workout_id, topset_id):
|
||||
db.delete_topset(topset_id)
|
||||
return ""
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/tag/add", methods=['POST'])
|
||||
def add_tag_to_workout(person_id, workout_id):
|
||||
tags_id = [int(i) for i in request.form.getlist('tag_id')]
|
||||
tags = db.add_tag_for_workout(workout_id, tags_id) # Keep using db.py for complex tag logic for now
|
||||
return render_template('partials/workout_tags_list.html', tags=tags)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/tag/new", methods=['POST'])
|
||||
def create_new_tag_for_workout(person_id, workout_id):
|
||||
tag_name = request.form.get('tag_name')
|
||||
workout_tags = db.create_tag_for_workout(person_id, workout_id, tag_name) # Keep using db.py for complex tag logic for now
|
||||
return render_template('partials/workout_tags_list.html', workout_tags=workout_tags)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])
|
||||
def get_most_recent_topset_for_exercise(person_id, workout_id):
|
||||
exercise_id = request.args.get('exercise_id', type=int)
|
||||
@@ -228,7 +216,8 @@ def get_most_recent_topset_for_exercise(person_id, workout_id):
|
||||
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises)
|
||||
topset = db.get_most_recent_topset_for_exercise(person_id, exercise_id) # Keep using db.py for now
|
||||
if not topset:
|
||||
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, exercise_id=exercise_id)
|
||||
exercise = db.execute("select name from exercise where exercise_id=%s", [exercise_id], one=True)
|
||||
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, exercise_id=exercise_id, exercise_name=exercise['name'])
|
||||
(repetitions, weight, exercise_name) = topset
|
||||
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercise_id=exercise_id, exercise_name=exercise_name, repetitions=repetitions, weight=weight)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
<script src="/static/js/sweetalert2@11.js" defer></script>
|
||||
<!-- Mermaid -->
|
||||
<script src="/static/js/mermaid.min.js"></script>
|
||||
<script src="/static/js/plotly-2.35.2.min.js" defer></script>
|
||||
<script>
|
||||
// Initialize Mermaid with startOnLoad set to false
|
||||
mermaid.initialize({
|
||||
@@ -156,6 +155,25 @@
|
||||
</div>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<a hx-get="{{ url_for('programs.list_programs') }}" hx-push-url="true"
|
||||
hx-target="#container"
|
||||
class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 group transition duration-75 flex items-center p-2 cursor-pointer {{ is_selected_page(url_for('sql_explorer.sql_explorer')) }} page-link"
|
||||
_="on click add .hidden to #sidebar then remove .ml-64 from #main
|
||||
on htmx:afterRequest go to the top of the body">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
||||
viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M6 5v14h3v-6h6v6h3V5h-3v6H9V5zM3 15a1 1 0 0 0 1 1h1V8H4a1 1 0 0 0-1 1v2H2v2h1v2zm18-6a1 1 0 0 0-1-1h-1v8h1a1 1 0 0 0 1-1v-2h1v-2h-1V9z">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="ml-3">Programs</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<a hx-get="{{ url_for('sql_explorer.sql_explorer') }}" hx-push-url="true"
|
||||
hx-target="#container"
|
||||
|
||||
@@ -10,6 +10,85 @@
|
||||
<div class="prose max-w-none">
|
||||
<p>Updates and changes to the site will be documented here, with the most recent changes listed first.</p>
|
||||
|
||||
<!-- New Entry for Workout Programs -->
|
||||
<hr class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-2">April 24, 2025</h2>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Added Workout Program Management:</li>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Created new database tables (`workout_program`, `program_session`, `person_program_assignment`).
|
||||
</li>
|
||||
<li>Added a new section under `/programs/` to create, view, and list workout program templates.</li>
|
||||
<li>Program creation allows defining multiple sessions, each with a name and a list of selected
|
||||
exercises.</li>
|
||||
<li>The system automatically finds or creates non-person-specific tags based on the selected
|
||||
exercises for each session.</li>
|
||||
<li>Added functionality to delete programs from the list view.</li>
|
||||
<li>Implemented HTMX for dynamic loading of the program view page from the list page.</li>
|
||||
<li>Integrated `tail.select` for searchable exercise dropdowns in the program creation form.</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<!-- New Entry for SQL Explorer SVG Plots -->
|
||||
<hr class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-2">April 19, 2025</h2>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Refactored tag management functionality:</li>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Moved tag-related routes (`add_tag`, `delete_tag`, `goto_tag`) from `app.py` to a new blueprint
|
||||
`routes/tags.py`.</li>
|
||||
<li>Changed `add_tag` endpoint to use `POST` and `delete_tag` to use `DELETE`.</li>
|
||||
<li>Updated `add_tag` and `delete_tag` to return the updated `tags.html` partial via HTMX swap.</li>
|
||||
<li>Wrapped the inclusion of `tags.html` in `dashboard.html` and `person_overview.html` with
|
||||
`div#container` for correct HTMX targeting.</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<!-- New Entry for SQL Explorer SVG Plots -->
|
||||
<hr class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-2">April 15, 2025</h2>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Replaced Plotly graph generation in SQL Explorer with direct SVG rendering:</li>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Updated `plot_query` and `plot_unsaved_query` endpoints in `routes/sql_explorer.py` to fetch raw
|
||||
data.</li>
|
||||
<li>Added `prepare_svg_plot_data` function in `utils.py` to process data and determine plot type
|
||||
(scatter, line, bar, or table fallback).</li>
|
||||
<li>Created `templates/partials/sql_explorer/svg_plot.html` template to render SVG plots with axes
|
||||
and basic tooltips.</li>
|
||||
<li>Removes the need for Plotly library for SQL Explorer plots, reducing dependencies and
|
||||
potentially improving load times.</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<!-- New Entry for Dismissible Exercise Graph -->
|
||||
<hr class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-2">April 13, 2025</h2>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Added a dismissible exercise progress graph to the workout page:</li>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Added a graph icon next to each exercise name in the topset list
|
||||
(`templates/partials/topset.html`).</li>
|
||||
<li>Clicking the icon loads the exercise progress graph inline using HTMX (`hx-get`, `hx-target`).
|
||||
</li>
|
||||
<li>Added a dismiss button (cross icon) to the loaded graph area.</li>
|
||||
<li>Implemented hyperscript (`_`) logic to show the dismiss button when the graph loads and clear
|
||||
the graph/hide the button when clicked.</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<!-- New Entry for Data Export -->
|
||||
<hr class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-2">April 12, 2025</h2>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Added functionality to export data from the Settings page:</li>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Export all workout set data as a CSV file.</li>
|
||||
<li>Export full database schema (CREATE statements) and data (INSERT statements) as an SQL script.
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<!-- New Entry for SQL Generation -->
|
||||
<hr class="my-6">
|
||||
<h2 class="text-xl font-semibold mb-2">April 5, 2025</h2>
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ render_partial('partials/tags.html',person_id=None, tags=tags) }}
|
||||
<div id="tags-container">
|
||||
{{ render_partial('partials/tags.html', person_id=None, tags=tags) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden" hx-get="{{ url_for('get_people_graphs') }}"
|
||||
@@ -160,7 +162,7 @@
|
||||
<tbody class="bg-white">
|
||||
|
||||
{% for set in exercise.sets %}
|
||||
<tr hx-get="{{ url_for('goto_tag') }}"
|
||||
<tr hx-get="{{ url_for('tags.goto_tag') }}"
|
||||
hx-vals='{"filter": "?exercise_id={{ set.exercise_id }}", "person_id" : "{{ person.id }}" }'
|
||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
|
||||
class="cursor-pointer">
|
||||
|
||||
125
templates/partials/sql_explorer/svg_plot.html
Normal file
125
templates/partials/sql_explorer/svg_plot.html
Normal file
@@ -0,0 +1,125 @@
|
||||
{# Basic SVG Plot Template for SQL Explorer #}
|
||||
{% set unique_id = range(1000, 9999) | random %} {# Simple unique ID for elements #}
|
||||
|
||||
<div class="sql-plot-container p-4 border rounded bg-white shadow" id="sql-plot-{{ unique_id }}">
|
||||
<h4 class="text-lg font-semibold text-gray-700 text-center mb-2">{{ title }}</h4>
|
||||
|
||||
{% if plot_type == 'table' %}
|
||||
{# Fallback to rendering a table if plot type is not supported or data is unsuitable #}
|
||||
<div class="overflow-x-auto max-h-96"> {# Limit height and allow scroll #}
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{% for col in original_columns %}
|
||||
<th scope="col" class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ col }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for row in original_results %}
|
||||
<tr>
|
||||
{% for col in original_columns %}
|
||||
<td class="px-4 py-2 whitespace-nowrap">
|
||||
{{ row[col] }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{{ original_columns|length }}" class="px-4 py-2 text-center text-gray-500">No data
|
||||
available.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# SVG Plot Area #}
|
||||
<div class="relative" _="
|
||||
on mouseover from .plot-point-{{ unique_id }}
|
||||
get event.target @data-tooltip
|
||||
if it
|
||||
put it into #tooltip-{{ unique_id }}
|
||||
remove .hidden from #tooltip-{{ unique_id }}
|
||||
end
|
||||
on mouseout from .plot-point-{{ unique_id }}
|
||||
add .hidden to #tooltip-{{ unique_id }}
|
||||
">
|
||||
|
||||
{# Tooltip Element #}
|
||||
<div id="tooltip-{{ unique_id }}"
|
||||
class="absolute top-0 left-0 hidden bg-gray-800 text-white text-xs p-1 rounded shadow-lg z-10 pointer-events-none">
|
||||
Tooltip
|
||||
</div>
|
||||
|
||||
<svg viewBox="0 0 {{ vb_width }} {{ vb_height }}" preserveAspectRatio="xMidYMid meet" class="w-full h-auto">
|
||||
{# Draw Axes #}
|
||||
<g class="axes" stroke="#6b7280" stroke-width="1">
|
||||
{# Y Axis #}
|
||||
<line x1="{{ margin.left }}" y1="{{ margin.top }}" x2="{{ margin.left }}"
|
||||
y2="{{ vb_height - margin.bottom }}"></line>
|
||||
{# X Axis #}
|
||||
<line x1="{{ margin.left }}" y1="{{ vb_height - margin.bottom }}" x2="{{ vb_width - margin.right }}"
|
||||
y2="{{ vb_height - margin.bottom }}"></line>
|
||||
</g>
|
||||
|
||||
{# Draw Ticks and Grid Lines #}
|
||||
<g class="ticks" font-size="10" fill="#6b7280" text-anchor="middle">
|
||||
{# Y Ticks #}
|
||||
{% for tick in y_ticks %}
|
||||
<line x1="{{ margin.left - 5 }}" y1="{{ tick.position }}" x2="{{ vb_width - margin.right }}"
|
||||
y2="{{ tick.position }}" stroke="#e5e7eb" stroke-width="0.5"></line> {# Grid line #}
|
||||
<text x="{{ margin.left - 8 }}" y="{{ tick.position + 3 }}" text-anchor="end">{{ tick.label }}</text>
|
||||
{% endfor %}
|
||||
{# X Ticks #}
|
||||
{% for tick in x_ticks %}
|
||||
<line x1="{{ tick.position }}" y1="{{ margin.top }}" x2="{{ tick.position }}"
|
||||
y2="{{ vb_height - margin.bottom + 5 }}" stroke="#e5e7eb" stroke-width="0.5"></line> {# Grid line #}
|
||||
<text x="{{ tick.position }}" y="{{ vb_height - margin.bottom + 15 }}">{{ tick.label }}</text>
|
||||
{% endfor %}
|
||||
</g>
|
||||
|
||||
{# Draw Axis Labels #}
|
||||
<g class="axis-labels" font-size="12" fill="#374151" text-anchor="middle">
|
||||
{# Y Axis Label #}
|
||||
<text
|
||||
transform="translate({{ margin.left / 2 - 5 }}, {{ (vb_height - margin.bottom + margin.top) / 2 }}) rotate(-90)">{{
|
||||
y_axis_label }}</text>
|
||||
{# X Axis Label #}
|
||||
<text x="{{ (vb_width - margin.right + margin.left) / 2 }}"
|
||||
y="{{ vb_height - margin.bottom / 2 + 10 }}">{{ x_axis_label }}</text>
|
||||
</g>
|
||||
|
||||
{# Plot Data Points/Bars #}
|
||||
{% for plot in plots %}
|
||||
<g class="plot-series-{{ loop.index }}" fill="{{ plot.color }}" stroke="{{ plot.color }}">
|
||||
{% if plot_type == 'scatter' %}
|
||||
{% for p in plot.points %}
|
||||
<circle cx="{{ p.x }}" cy="{{ p.y }}" r="3" class="plot-point-{{ unique_id }}"
|
||||
data-tooltip="{{ p.original | tojson | escape }}" />
|
||||
{% endfor %}
|
||||
{% elif plot_type == 'line' %}
|
||||
<path
|
||||
d="{% for p in plot.points %}{% if loop.first %}M{% else %}L{% endif %}{{ p.x }} {{ p.y }}{% endfor %}"
|
||||
fill="none" stroke-width="1.5" />
|
||||
{% for p in plot.points %}
|
||||
<circle cx="{{ p.x }}" cy="{{ p.y }}" r="2.5" class="plot-point-{{ unique_id }}"
|
||||
data-tooltip="{{ p.original | tojson | escape }}" />
|
||||
{% endfor %}
|
||||
{% elif plot_type == 'bar' %}
|
||||
{% set bar_w = bar_width | default(10) %}
|
||||
{% for p in plot.points %}
|
||||
<rect x="{{ p.x - bar_w / 2 }}" y="{{ p.y }}" width="{{ bar_w }}"
|
||||
height="{{ (vb_height - margin.bottom) - p.y }}" stroke-width="0.5"
|
||||
class="plot-point-{{ unique_id }}" data-tooltip="{{ p.original | tojson | escape }}" />
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</g>
|
||||
{% endfor %}
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,23 +1,24 @@
|
||||
<div class="flex w-full flex-wrap justify-center animate-fadeIn">
|
||||
{# Container for the tags partial, needed for HTMX swapping #}
|
||||
<div id="tags-container" class="flex w-full flex-wrap justify-center items-center animate-fadeIn space-x-2">
|
||||
|
||||
{# Display Existing Tags #}
|
||||
{% for t in tags %}
|
||||
<div data-te-chip-init data-te-ripple-init
|
||||
class="[word-wrap: break-word] my-[5px] mr-4 flex h-[32px] cursor-pointer items-center justify-between rounded-[16px] border border-[#9fa6b2] bg-[#eceff1] bg-[transparent] py-0 px-[12px] text-[13px] font-normal normal-case leading-loose text-[#4f4f4f] shadow-none transition-[opacity] duration-300 ease-linear hover:border-[#9fa6b2] hover:!shadow-none dark:text-neutral-200"
|
||||
class="[word-wrap: break-word] my-1 flex h-8 cursor-pointer items-center justify-between rounded-full border border-gray-300 bg-gray-100 py-0 px-3 text-sm font-normal text-gray-700 shadow-none transition-opacity duration-300 ease-linear hover:border-gray-400 hover:shadow-sm dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200"
|
||||
data-te-ripple-color="dark">
|
||||
<span hx-get="{{ url_for('goto_tag') }}" {% if person_id %}
|
||||
hx-vals='{"filter": "{{ t["tag_filter"] }}", "person_id": "{{ person_id }}"}' {% else %}
|
||||
hx-vals='{"filter": "{{ t["tag_filter"] }}"}' {% endif%} hx-target="#container" hx-push-url="true">{{
|
||||
t['tag_name'] }}</span>
|
||||
|
||||
{# Tag Name (Clickable to filter) #}
|
||||
<span class="pr-2" hx-get="{{ url_for('tags.goto_tag') }}"
|
||||
hx-vals='{"filter": "{{ t.tag_filter }}", "person_id": "{{ person_id | default("", true) }}"}'
|
||||
hx-target="#container" hx-push-url="true">{{ t.tag_name }}</span>
|
||||
|
||||
{# Delete Button #}
|
||||
<span
|
||||
class="float-right w-4 cursor-pointer pl-[8px] text-[16px] text-[#afafaf] opacity-[.53] transition-all duration-200 ease-in-out hover:text-[#8b8b8b] dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
hx-get="{{ url_for('delete_tag', tag_id=t['tag_id']) }}" {% if person_id %}
|
||||
hx-vals='{"filter": "{{ t["tag_filter"] }}", "person_id": "{{ person_id }}"}' {% else %}
|
||||
hx-vals='{"filter": "{{ t["tag_filter"] }}"}' {% endif%} hx-target="#container" hx-push-url="true" _="on htmx:confirm(issueRequest)
|
||||
halt the event
|
||||
call Swal.fire({title: 'Confirm', text:'Are you sure you want to delete {{ t['tag_name'] }} tag?'})
|
||||
if result.isConfirmed issueRequest()">
|
||||
class="ml-1 cursor-pointer text-gray-400 hover:text-gray-600 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
hx-delete="{{ url_for('tags.delete_tag', tag_id=t.tag_id) }}" hx-target="#tags-container" {# Target the
|
||||
container to refresh the list #} hx-swap="outerHTML" {# Replace the whole container #}
|
||||
hx-confirm="Are you sure you want to delete the '{{ t.tag_name }}' tag?"
|
||||
hx-vals='{"person_id": "{{ person_id | default("", true) }}", "current_filter": "{{ request.query_string | default("", true) }}"}'>
|
||||
{# Pass context if needed by backend #}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="h-3 w-3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -26,52 +27,44 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="flex justify-center space-x-2">
|
||||
<div>
|
||||
<button type="button" data-te-ripple-init data-te-ripple-color="light"
|
||||
class="inline-block rounded-full bg-primary p-2 uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-primary-600 hover:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:bg-primary-600 focus:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:outline-none focus:ring-0 active:bg-primary-700 active:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)]"
|
||||
id="add-tag">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
{# Add Tag Section - Initially Hidden Button, reveals Form #}
|
||||
<div id="add-tag-section" class="my-1">
|
||||
{# Show Add Button #}
|
||||
<button id="show-add-tag-form-btn"
|
||||
class="inline-block rounded-full bg-blue-500 p-2 text-xs font-medium uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-blue-600 hover:shadow-lg focus:bg-blue-600 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-700 active:shadow-lg"
|
||||
_="on click toggle .hidden on #add-tag-form then toggle .hidden on me">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# Add Tag Form (Initially Hidden) #}
|
||||
<form id="add-tag-form" class="hidden flex items-center space-x-1" hx-post="{{ url_for('tags.add_tag') }}"
|
||||
hx-target="#tags-container" {# Target the container to refresh the list #} hx-swap="outerHTML" {# Replace
|
||||
the whole container #}
|
||||
_="on htmx:afterRequest toggle .hidden on #show-add-tag-form-btn then toggle .hidden on me then set me.tag_name.value to ''">
|
||||
{# Hide form, show button, clear input after submit #}
|
||||
|
||||
<input type="hidden" name="person_id" value="{{ person_id | default('', true) }}">
|
||||
<input type="hidden" name="current_filter" value="{{ request.query_string.decode() | default('', true) }}">
|
||||
{# Pass
|
||||
context
|
||||
if needed #}
|
||||
|
||||
<input type="text" name="tag_name" required
|
||||
class="h-8 rounded border border-gray-300 px-2 text-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="New tag...">
|
||||
<button type="submit"
|
||||
class="inline-block rounded bg-green-500 px-3 py-1.5 text-xs font-medium uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-green-600 hover:shadow-lg focus:bg-green-600 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-green-700 active:shadow-lg">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="inline-block rounded bg-gray-400 px-3 py-1.5 text-xs font-medium uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-gray-500 hover:shadow-lg focus:bg-gray-500 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-gray-600 active:shadow-lg"
|
||||
_="on click toggle .hidden on #show-add-tag-form-btn then toggle .hidden on the closest <form/>">
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelector('#add-tag').addEventListener('click', function () {
|
||||
Swal.fire({
|
||||
title: 'Create a tag',
|
||||
input: 'text',
|
||||
inputAttributes: {
|
||||
autocapitalize: 'off'
|
||||
},
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Add',
|
||||
showLoaderOnConfirm: true,
|
||||
preConfirm: (tag) => {
|
||||
return fetch(`{{ url_for('add_tag') }}?tag=${encodeURIComponent(tag)}&filter=${encodeURIComponent(window.location.search)}{% if person_id %}{{ "&person_id={person_id}".format(person_id=person_id) | safe }} {% endif%}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText)
|
||||
}
|
||||
return response.text()
|
||||
})
|
||||
.catch(error => {
|
||||
Swal.showValidationMessage(
|
||||
`Request failed: ${error}`
|
||||
)
|
||||
})
|
||||
},
|
||||
allowOutsideClick: () => !Swal.isLoading()
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
htmx.ajax('GET', `{{ (url_for('person_overview', person_id=person_id) if person_id else url_for('dashboard')) + '?' + request.query_string.decode() }}`, '#container')
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
</script>
|
||||
</div>
|
||||
@@ -1,11 +1,24 @@
|
||||
<tr id="topset-{{ topset_id }}">
|
||||
<td class="p-0 sm:p-4 text-sm font-semibold text-gray-900 break-normal">
|
||||
{% if is_edit|default(false, true) == false %}
|
||||
<span class="cursor-pointer" hx-get="{{ url_for('goto_tag') }}"
|
||||
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
|
||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
|
||||
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name
|
||||
}}</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="cursor-pointer" hx-get="{{ url_for('tags.goto_tag') }}"
|
||||
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
|
||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
|
||||
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name }}</span>
|
||||
<button
|
||||
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
|
||||
title="Show Progress Graph"
|
||||
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
|
||||
hx-target="#graph-content-{{ topset_id }}" hx-swap="innerHTML">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Show Progress Graph</span>
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-full">
|
||||
<select name="exercise_id"
|
||||
@@ -90,4 +103,34 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{# Target row modified for dismissible graph #}
|
||||
<tr id="graph-target-{{ topset_id }}">
|
||||
<td colspan="3" class="p-0 relative">
|
||||
<div id="graph-content-{{ topset_id }}" class="graph-content-container" _="
|
||||
on htmx:afterSwap
|
||||
get the next <button.dismiss-button/>
|
||||
if my.innerHTML is not empty and my.innerHTML is not ' '
|
||||
remove .hidden from it
|
||||
else
|
||||
add .hidden to it
|
||||
end
|
||||
end">
|
||||
<!-- Progress graph will be loaded here -->
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-1 right-1 p-1 bg-white rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 z-10 dismiss-button hidden"
|
||||
title="Dismiss Graph" _="on click
|
||||
get #graph-content-{{ topset_id }}
|
||||
set its innerHTML to ''
|
||||
add .hidden to me
|
||||
end">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Dismiss Graph</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="relative">
|
||||
<div class="w-full">
|
||||
<select multiple name="tag_id"
|
||||
hx-post="{{ url_for('workout.add_tag_to_workout', person_id=person_id, workout_id=workout_id) }}"
|
||||
hx-post="{{ url_for('tags.add_tag_to_workout', workout_id=workout_id) }}"
|
||||
hx-target="#tag-wrapper-w-{{ workout_id }}"
|
||||
class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
||||
_="init js(me)
|
||||
@@ -65,8 +65,7 @@
|
||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
||||
type="text" name="tag_name">
|
||||
|
||||
<button type="submit"
|
||||
hx-post="{{ url_for('workout.create_new_tag_for_workout', person_id=person_id, workout_id=workout_id) }}"
|
||||
<button type="submit" hx-post="{{ url_for('tags.create_new_tag_for_workout', workout_id=workout_id) }}"
|
||||
hx-include="[name='tag_name']" hx-target="#tag-wrapper-w-{{ workout_id }}"
|
||||
class="p-2.5 ml-2 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{% if tags|length == 0 %}
|
||||
{% if tags|length > 0 %}
|
||||
{% for tag in tags %}
|
||||
{% if tag.is_selected %}
|
||||
<span
|
||||
class="text-xs font-semibold inline-block py-1 px-2 uppercase rounded text-pink-600 bg-pink-200 uppercase last:mr-0 mr-1 max-h-fit cursor-pointer"
|
||||
hx-get="{{ url_for('goto_tag') }}" hx-vals='{"filter": "{{ tag.tag_filter }}", "person_id": "{{ tag.person_id }}"}'
|
||||
hx-target="#container" hx-push-url="true">
|
||||
hx-get="{{ url_for('tags.goto_tag') }}"
|
||||
hx-vals='{"filter": "{{ tag.tag_filter }}", "person_id": "{{ tag.person_id }}"}' hx-target="#container"
|
||||
hx-push-url="true">
|
||||
{{ tag.tag_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ render_partial('partials/tags.html',person_id=person_id, tags=tags) }}
|
||||
<div id="tags-container">
|
||||
{{ render_partial('partials/tags.html', person_id=person_id, tags=tags) }}
|
||||
</div>
|
||||
|
||||
<div class="hidden" hx-get="{{ url_for('get_people_graphs') }}"
|
||||
hx-include="[name='exercise_id'],[name='min_date'],[name='max_date']"
|
||||
|
||||
318
templates/program_create.html
Normal file
318
templates/program_create.html
Normal file
@@ -0,0 +1,318 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Workout Program{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-3xl"> {# Constrain width #}
|
||||
<h1 class="text-3xl font-bold mb-8 text-center text-gray-800">Create New Workout Program</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('programs.create_program') }}" id="create-program-form"
|
||||
class="bg-white shadow-md rounded-lg px-8 pt-6 pb-8 mb-4">
|
||||
{# Program Details Section #}
|
||||
<div class="mb-6 border-b border-gray-200 pb-4">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-700">Program Details</h2>
|
||||
<div class="mb-4">
|
||||
<label for="program_name" class="block text-gray-700 text-sm font-bold mb-2">Program Name:</label>
|
||||
<input type="text" id="program_name" name="program_name" required
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-gray-700 text-sm font-bold mb-2">Description
|
||||
(Optional):</label>
|
||||
<textarea id="description" name="description" rows="3"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sessions Section #}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-700">Sessions</h2>
|
||||
<div id="sessions-container" class="space-y-6 mb-4"> {# Increased spacing #}
|
||||
<!-- Session rows will be added here by JavaScript -->
|
||||
</div>
|
||||
<button type="button" id="add-session-btn"
|
||||
class="mt-2 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Add Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{# Form Actions #}
|
||||
<div class="flex items-center justify-end pt-4 border-t border-gray-200">
|
||||
<button type="submit"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
|
||||
Create Program
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# HTML Template for a single session row #}
|
||||
<template id="session-row-template">
|
||||
<div class="session-row bg-gray-50 border border-gray-300 rounded-lg shadow-sm overflow-hidden"
|
||||
data-index="SESSION_INDEX_PLACEHOLDER">
|
||||
{# Session Header #}
|
||||
<div class="px-4 py-3 bg-gray-100 border-b border-gray-300 flex justify-between items-center">
|
||||
<h3 class="session-day-number text-lg font-semibold text-gray-700">Day SESSION_DAY_NUMBER_PLACEHOLDER
|
||||
</h3>
|
||||
<input type="hidden" name="session_order_SESSION_INDEX_PLACEHOLDER"
|
||||
value="SESSION_DAY_NUMBER_PLACEHOLDER">
|
||||
<button type="button" class="remove-session-btn text-red-500 hover:text-red-700" title="Remove Session">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Session Body #}
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<label for="session_name_SESSION_INDEX_PLACEHOLDER"
|
||||
class="block text-sm font-medium text-gray-700 mb-1">Session Name (Optional):</label>
|
||||
<input type="text" id="session_name_SESSION_INDEX_PLACEHOLDER"
|
||||
name="session_name_SESSION_INDEX_PLACEHOLDER" value=""
|
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||
</div>
|
||||
{# Container for individual exercise selects #}
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Exercises:</label>
|
||||
<div class="session-exercises-container space-y-2 border border-gray-200 p-3 rounded-md bg-white">
|
||||
{# Exercise rows will be added here by JS #}
|
||||
</div>
|
||||
<button type="button"
|
||||
class="add-exercise-btn mt-1 inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Add Exercise to Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Nested Template for a single exercise row within a session #}
|
||||
<template id="exercise-row-template">
|
||||
<div class="exercise-row flex items-center space-x-2">
|
||||
{# Wrapper div for tail.select - Added position: relative #}
|
||||
<div class="flex-grow relative">
|
||||
{# Note: tail.select might hide the original select, apply styling to its container if needed #}
|
||||
<select name="exercises_SESSION_INDEX_PLACEHOLDER" required class="exercise-select-original w-full"> {#
|
||||
Keep original select for form submission, tail.select will enhance it #}
|
||||
<option value="">Select Exercise...</option>
|
||||
{# Render options directly here using the exercises passed to the main template #}
|
||||
{% for exercise in exercises %}
|
||||
<option value="{{ exercise.exercise_id }}">{{ exercise.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="remove-exercise-btn text-red-500 hover:text-red-700 flex-shrink-0"
|
||||
title="Remove Exercise">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// No longer need to pass exercises to JS for populating options
|
||||
// const availableExercises = {{ exercises | tojson | safe }}; // Removed
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const sessionsContainer = document.getElementById('sessions-container');
|
||||
const addSessionBtn = document.getElementById('add-session-btn');
|
||||
const sessionTemplate = document.getElementById('session-row-template');
|
||||
const exerciseTemplate = document.getElementById('exercise-row-template');
|
||||
let sessionCounter = sessionsContainer.querySelectorAll('.session-row').length;
|
||||
|
||||
// --- Function to add a new session row ---
|
||||
function addSessionRow() {
|
||||
const newRowFragment = sessionTemplate.content.cloneNode(true);
|
||||
const newRow = newRowFragment.querySelector('.session-row');
|
||||
const currentSessionIndex = sessionCounter;
|
||||
|
||||
if (!newRow) {
|
||||
console.error("Failed to clone session row template.");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Update placeholders and attributes for Session ---
|
||||
newRow.dataset.index = currentSessionIndex;
|
||||
|
||||
const dayNumberSpan = newRow.querySelector('.session-day-number');
|
||||
if (dayNumberSpan) dayNumberSpan.textContent = `Day ${currentSessionIndex + 1}`;
|
||||
|
||||
const orderInput = newRow.querySelector('input[type="hidden"]');
|
||||
if (orderInput) {
|
||||
orderInput.name = `session_order_${currentSessionIndex}`;
|
||||
orderInput.value = currentSessionIndex + 1;
|
||||
}
|
||||
|
||||
const nameLabel = newRow.querySelector('label[for^="session_name_"]');
|
||||
const nameInput = newRow.querySelector('input[id^="session_name_"]');
|
||||
if (nameLabel) nameLabel.htmlFor = `session_name_${currentSessionIndex}`;
|
||||
if (nameInput) {
|
||||
nameInput.id = `session_name_${currentSessionIndex}`;
|
||||
nameInput.name = `session_name_${currentSessionIndex}`;
|
||||
}
|
||||
// --- End Session Placeholder Updates ---
|
||||
|
||||
// Attach listener for the "Add Exercise" button within this new session
|
||||
const addExerciseBtn = newRow.querySelector('.add-exercise-btn');
|
||||
const exercisesContainer = newRow.querySelector('.session-exercises-container');
|
||||
if (addExerciseBtn && exercisesContainer) {
|
||||
addExerciseBtn.dataset.sessionIndex = currentSessionIndex; // Store index
|
||||
addExerciseBtn.addEventListener('click', handleAddExerciseClick);
|
||||
// Add one exercise select automatically when session is added
|
||||
addExerciseSelect(exercisesContainer, currentSessionIndex);
|
||||
}
|
||||
|
||||
sessionsContainer.appendChild(newRowFragment);
|
||||
attachRemoveListener(newRow.querySelector('.remove-session-btn')); // Attach session remove listener
|
||||
sessionCounter++;
|
||||
}
|
||||
|
||||
// --- Function to add an exercise select row to a specific session ---
|
||||
function addExerciseSelect(container, sessionIndex) {
|
||||
const newExFragment = exerciseTemplate.content.cloneNode(true);
|
||||
const originalSelect = newExFragment.querySelector('.exercise-select-original');
|
||||
const removeBtn = newExFragment.querySelector('.remove-exercise-btn');
|
||||
|
||||
if (!originalSelect || !removeBtn) {
|
||||
console.error("Failed to find original select or remove button in exercise template clone.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the name attribute correctly for getlist
|
||||
originalSelect.name = `exercises_${sessionIndex}`;
|
||||
|
||||
container.appendChild(newExFragment);
|
||||
|
||||
// Find the newly added select element *after* appending
|
||||
const newSelectElement = container.querySelector('.exercise-row:last-child .exercise-select-original');
|
||||
|
||||
// Initialize tail.select on the new element
|
||||
if (newSelectElement && typeof tail !== 'undefined' && tail.select) {
|
||||
tail.select(newSelectElement, {
|
||||
search: true,
|
||||
placeholder: 'Select Exercise...',
|
||||
// classNames: "w-full" // Add tailwind classes if needed for the generated dropdown
|
||||
});
|
||||
} else {
|
||||
console.warn("tail.select library not found or new select element not found. Using standard select.");
|
||||
}
|
||||
|
||||
// Attach remove listener to the new exercise row's button
|
||||
attachExerciseRemoveListener(removeBtn);
|
||||
}
|
||||
|
||||
// --- Event handler for Add Exercise buttons ---
|
||||
function handleAddExerciseClick(event) {
|
||||
const btn = event.currentTarget;
|
||||
const sessionIndex = parseInt(btn.dataset.sessionIndex, 10);
|
||||
const exercisesContainer = btn.closest('.session-row').querySelector('.session-exercises-container');
|
||||
if (!isNaN(sessionIndex) && exercisesContainer) {
|
||||
addExerciseSelect(exercisesContainer, sessionIndex);
|
||||
} else {
|
||||
console.error("Could not find session index or container for Add Exercise button.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Function to attach remove listener for Session rows ---
|
||||
function attachRemoveListener(button) {
|
||||
button.addEventListener('click', function () {
|
||||
this.closest('.session-row').remove();
|
||||
updateSessionNumbers(); // Renumber sessions after removal
|
||||
});
|
||||
}
|
||||
|
||||
// --- Function to attach remove listener for Exercise rows ---
|
||||
function attachExerciseRemoveListener(button) {
|
||||
if (button) {
|
||||
button.addEventListener('click', function () {
|
||||
this.closest('.exercise-row').remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Function to renumber sessions ---
|
||||
function updateSessionNumbers() {
|
||||
const rows = sessionsContainer.querySelectorAll('.session-row');
|
||||
sessionCounter = 0; // Reset counter before renumbering
|
||||
rows.forEach((row, index) => {
|
||||
const newIndex = index;
|
||||
sessionCounter++; // Increment counter for the next row index
|
||||
|
||||
// Update visible day number
|
||||
const daySpan = row.querySelector('.session-day-number');
|
||||
if (daySpan) daySpan.textContent = `Day ${newIndex + 1}`;
|
||||
|
||||
// Update hidden order input value and name
|
||||
const orderInput = row.querySelector('input[type="hidden"]');
|
||||
if (orderInput) {
|
||||
orderInput.name = `session_order_${newIndex}`;
|
||||
orderInput.value = newIndex + 1;
|
||||
}
|
||||
|
||||
// Update IDs and names for session name input/label
|
||||
const nameLabel = row.querySelector('label[for^="session_name_"]');
|
||||
const nameInput = row.querySelector('input[id^="session_name_"]');
|
||||
|
||||
if (nameLabel) nameLabel.htmlFor = `session_name_${newIndex}`;
|
||||
if (nameInput) {
|
||||
nameInput.id = `session_name_${newIndex}`;
|
||||
nameInput.name = `session_name_${newIndex}`;
|
||||
}
|
||||
|
||||
// Update names for the exercise selects within this session
|
||||
const exerciseSelects = row.querySelectorAll('.exercise-select-original'); // Target original selects
|
||||
exerciseSelects.forEach(select => {
|
||||
select.name = `exercises_${newIndex}`;
|
||||
});
|
||||
|
||||
// Update listener for the "Add Exercise" button
|
||||
const addExerciseBtn = row.querySelector('.add-exercise-btn');
|
||||
if (addExerciseBtn) {
|
||||
addExerciseBtn.dataset.sessionIndex = newIndex; // Update index used by listener
|
||||
// No need to re-attach listener if it uses the dataset property correctly
|
||||
}
|
||||
|
||||
// Update data-index attribute
|
||||
row.dataset.index = newIndex;
|
||||
});
|
||||
sessionCounter = rows.length;
|
||||
}
|
||||
|
||||
|
||||
// --- Event Listeners ---
|
||||
addSessionBtn.addEventListener('click', addSessionRow);
|
||||
|
||||
// Attach listeners to initially loaded elements (if any)
|
||||
sessionsContainer.querySelectorAll('.session-row .remove-session-btn').forEach(attachRemoveListener);
|
||||
sessionsContainer.querySelectorAll('.exercise-row .remove-exercise-btn').forEach(attachExerciseRemoveListener);
|
||||
sessionsContainer.querySelectorAll('.session-row .add-exercise-btn').forEach(btn => {
|
||||
const sessionIndex = parseInt(btn.closest('.session-row').dataset.index, 10);
|
||||
if (!isNaN(sessionIndex)) {
|
||||
btn.dataset.sessionIndex = sessionIndex;
|
||||
btn.addEventListener('click', handleAddExerciseClick);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add one session row automatically if none exist initially
|
||||
if (sessionsContainer.children.length === 0) {
|
||||
addSessionRow();
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
85
templates/program_list.html
Normal file
85
templates/program_list.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Workout Programs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Workout Programs</h1>
|
||||
<a href="{{ url_for('programs.create_program') }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Create New Program
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for category, message in messages %}
|
||||
<div class="p-4 rounded-md {{ 'bg-green-100 border-green-400 text-green-700' if category == 'success' else 'bg-red-100 border-red-400 text-red-700' }}"
|
||||
role="alert">
|
||||
<p class="font-bold">{{ category.title() }}</p>
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
{% if programs %}
|
||||
{% for program in programs %}
|
||||
<li id="program-{{ program.program_id }}">
|
||||
{# Use HTMX for dynamic loading #}
|
||||
<a href="{{ url_for('programs.view_program', program_id=program.program_id) }}"
|
||||
class="block hover:bg-gray-50"
|
||||
hx-get="{{ url_for('programs.view_program', program_id=program.program_id) }}"
|
||||
hx-target="#container" hx-push-url="true" hx-swap="innerHTML">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-indigo-600 truncate">{{ program.name }}</p>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2"> {# Added space-x-2 #}
|
||||
{# TODO: Add View/Edit/Assign buttons later #}
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 items-center">
|
||||
{# Added items-center #}
|
||||
ID: {{ program.program_id }}
|
||||
</span>
|
||||
{# Delete Button #}
|
||||
<button type="button" class="text-red-600 hover:text-red-800 focus:outline-none"
|
||||
hx-delete="{{ url_for('programs.delete_program', program_id=program.program_id) }}"
|
||||
hx-target="closest li" hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete the program '{{ program.name }}'? This cannot be undone.">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-gray-500">
|
||||
{{ program.description | default('No description provided.') }}
|
||||
</p>
|
||||
</div>
|
||||
{# <div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
||||
Created: {{ program.created_at | strftime('%Y-%m-%d') }}
|
||||
</div> #}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="px-4 py-4 sm:px-6">
|
||||
<p class="text-sm text-gray-500">No workout programs found. Create one!</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
82
templates/program_view.html
Normal file
82
templates/program_view.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ program.name }} - Program Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
|
||||
{# Back Link #}
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('programs.list_programs') }}" hx-get="{{ url_for('programs.list_programs') }}"
|
||||
hx-target="#container" hx-push-url="true" hx-swap="innerHTML"
|
||||
class="text-indigo-600 hover:text-indigo-800 text-sm">
|
||||
← Back to Programs List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Program Header #}
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h1 class="text-2xl leading-6 font-bold text-gray-900">
|
||||
{{ program.name }}
|
||||
</h1>
|
||||
{% if program.description %}
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
{{ program.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{# Add Edit/Assign buttons here later #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sessions Section #}
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-700">Sessions</h2>
|
||||
<div class="space-y-6">
|
||||
{% if sessions %}
|
||||
{% for session in sessions %}
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-4 sm:px-6 bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Day {{ session.session_order }}{% if session.session_name %}: {{ session.session_name }}{% endif %}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Tag: {{ session.tag_name }} (ID: {{ session.tag_id }})</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">
|
||||
Exercises
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{% if session.exercises %}
|
||||
<ul role="list" class="border border-gray-200 rounded-md divide-y divide-gray-200">
|
||||
{% for exercise in session.exercises %}
|
||||
<li class="pl-3 pr-4 py-3 flex items-center justify-between text-sm">
|
||||
<div class="w-0 flex-1 flex items-center">
|
||||
<!-- Heroicon name: solid/paper-clip -->
|
||||
{# Could add an icon here #}
|
||||
<span class="ml-2 flex-1 w-0 truncate">
|
||||
{{ exercise.name }} (ID: {{ exercise.exercise_id }})
|
||||
</span>
|
||||
</div>
|
||||
{# Add links/actions per exercise later if needed #}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-gray-500 italic">No exercises found for this session's tag filter.</p>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{# Add more session details here if needed #}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-gray-500 italic">This program currently has no sessions defined.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -182,6 +182,39 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Data Export Section -->
|
||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Data Export</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4"> <!-- Added space-y-4 for spacing between buttons -->
|
||||
<p class="text-sm text-gray-600">Download all workout set data as a CSV file, or the entire database
|
||||
structure and data as an SQL script.</p>
|
||||
<a href="{{ url_for('export.export_workouts_csv') }}" class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium
|
||||
rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
|
||||
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v3.586l-1.293-1.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V8z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Export All Workouts (CSV)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_database_sql') }}"
|
||||
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
|
||||
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg> <!-- Using a generic download/database icon -->
|
||||
Export Database (SQL Script)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
230
utils.py
230
utils.py
@@ -3,7 +3,9 @@ from datetime import datetime, date, timedelta
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.io as pio
|
||||
import plotly.io as pio # Keep for now, might remove later if generate_plot is fully replaced
|
||||
import math
|
||||
from decimal import Decimal
|
||||
|
||||
def convert_str_to_date(date_str, format='%Y-%m-%d'):
|
||||
try:
|
||||
@@ -141,4 +143,228 @@ def calculate_estimated_1rm(weight, repetitions):
|
||||
if repetitions == 0: # Avoid division by zero
|
||||
return 0
|
||||
estimated_1rm = round((100 * int(weight)) / (101.3 - 2.67123 * repetitions), 0)
|
||||
return int(estimated_1rm)
|
||||
return int(estimated_1rm)
|
||||
|
||||
|
||||
def _is_numeric(val):
|
||||
"""Check if a value is numeric (int, float, Decimal)."""
|
||||
return isinstance(val, (int, float, Decimal))
|
||||
|
||||
def _is_datetime(val):
|
||||
"""Check if a value is a date or datetime object."""
|
||||
return isinstance(val, (date, datetime))
|
||||
|
||||
def _get_column_type(results, column_name):
|
||||
"""Determine the effective type of a column (numeric, datetime, categorical)."""
|
||||
numeric_count = 0
|
||||
datetime_count = 0
|
||||
total_count = 0
|
||||
for row in results:
|
||||
val = row.get(column_name)
|
||||
if val is not None:
|
||||
total_count += 1
|
||||
if _is_numeric(val):
|
||||
numeric_count += 1
|
||||
elif _is_datetime(val):
|
||||
datetime_count += 1
|
||||
if total_count == 0: return 'categorical' # Default if all null or empty
|
||||
if numeric_count / total_count > 0.8: return 'numeric' # Allow some non-numeric noise
|
||||
if datetime_count / total_count > 0.8: return 'datetime'
|
||||
return 'categorical'
|
||||
|
||||
def _normalize_value(value, min_val, range_val, target_max):
|
||||
"""Normalize a value to a target range (e.g., SVG coordinate)."""
|
||||
if range_val == 0: return target_max / 2 # Avoid division by zero, place in middle
|
||||
return ((value - min_val) / range_val) * target_max
|
||||
|
||||
def prepare_svg_plot_data(results, columns, title):
|
||||
"""
|
||||
Prepares data from raw SQL results for SVG plotting.
|
||||
Determines plot type and scales data.
|
||||
"""
|
||||
if not results:
|
||||
raise ValueError("No data provided for plotting.")
|
||||
|
||||
num_columns = len(columns)
|
||||
plot_type = 'table' # Default if no suitable plot found
|
||||
plot_data = {}
|
||||
x_col, y_col = None, None
|
||||
x_type, y_type = None, None
|
||||
|
||||
# --- Determine Plot Type and Columns ---
|
||||
if num_columns == 1:
|
||||
x_col = columns[0]
|
||||
x_type = _get_column_type(results, x_col)
|
||||
if x_type == 'numeric':
|
||||
plot_type = 'histogram'
|
||||
else:
|
||||
plot_type = 'bar_count' # Bar chart of value counts
|
||||
|
||||
elif num_columns >= 2:
|
||||
# Prioritize common patterns
|
||||
x_col, y_col = columns[0], columns[1]
|
||||
x_type = _get_column_type(results, x_col)
|
||||
y_type = _get_column_type(results, y_col)
|
||||
|
||||
if x_type == 'numeric' and y_type == 'numeric':
|
||||
plot_type = 'scatter'
|
||||
elif x_type == 'datetime' and y_type == 'numeric':
|
||||
plot_type = 'line' # Treat datetime as numeric for position
|
||||
elif x_type == 'categorical' and y_type == 'numeric':
|
||||
plot_type = 'bar'
|
||||
elif x_type == 'numeric' and y_type == 'categorical':
|
||||
# Could do horizontal bar, but let's stick to vertical for now
|
||||
plot_type = 'bar' # Treat numeric as category label, categorical as value (count?) - less common
|
||||
# Or maybe swap? Let's assume categorical X, numeric Y is more likely intended
|
||||
x_col, y_col = columns[1], columns[0] # Try swapping
|
||||
x_type, y_type = y_type, x_type
|
||||
if not (x_type == 'categorical' and y_type == 'numeric'):
|
||||
plot_type = 'table' # Revert if swap didn't help
|
||||
|
||||
else: # Other combinations (datetime/cat, cat/cat, etc.) default to table
|
||||
plot_type = 'table'
|
||||
|
||||
# --- Basic SVG Setup ---
|
||||
vb_width = 500
|
||||
vb_height = 300
|
||||
margin = {'top': 20, 'right': 20, 'bottom': 50, 'left': 60} # Increased bottom/left for labels/axes
|
||||
draw_width = vb_width - margin['left'] - margin['right']
|
||||
draw_height = vb_height - margin['top'] - margin['bottom']
|
||||
|
||||
plot_data = {
|
||||
'title': title,
|
||||
'plot_type': plot_type,
|
||||
'vb_width': vb_width,
|
||||
'vb_height': vb_height,
|
||||
'margin': margin,
|
||||
'draw_width': draw_width,
|
||||
'draw_height': draw_height,
|
||||
'x_axis_label': x_col or '',
|
||||
'y_axis_label': y_col or '',
|
||||
'plots': [],
|
||||
'x_ticks': [],
|
||||
'y_ticks': [],
|
||||
'original_results': results, # Keep original for table fallback
|
||||
'original_columns': columns
|
||||
}
|
||||
|
||||
if plot_type == 'table':
|
||||
return plot_data # No further processing needed for table fallback
|
||||
|
||||
# --- Data Extraction and Scaling (Specific to Plot Type) ---
|
||||
points = []
|
||||
x_values_raw = []
|
||||
y_values_raw = []
|
||||
|
||||
# Extract relevant data, handling potential type issues
|
||||
for row in results:
|
||||
x_val_raw = row.get(x_col)
|
||||
y_val_raw = row.get(y_col)
|
||||
|
||||
# Convert datetimes to numeric representation (e.g., days since min date)
|
||||
if x_type == 'datetime':
|
||||
x_values_raw.append(x_val_raw) # Keep original dates for range calculation
|
||||
elif _is_numeric(x_val_raw):
|
||||
x_values_raw.append(float(x_val_raw)) # Convert Decimal to float
|
||||
# Add handling for categorical X if needed (e.g., bar chart)
|
||||
|
||||
if y_type == 'numeric':
|
||||
if _is_numeric(y_val_raw):
|
||||
y_values_raw.append(float(y_val_raw))
|
||||
else:
|
||||
y_values_raw.append(None) # Mark non-numeric Y as None
|
||||
# Add handling for categorical Y if needed
|
||||
|
||||
if not x_values_raw or not y_values_raw:
|
||||
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
|
||||
return plot_data
|
||||
|
||||
# Calculate ranges (handle datetime separately)
|
||||
if x_type == 'datetime':
|
||||
valid_dates = [d for d in x_values_raw if d is not None]
|
||||
if not valid_dates:
|
||||
plot_data['plot_type'] = 'table'; return plot_data
|
||||
min_x_dt, max_x_dt = min(valid_dates), max(valid_dates)
|
||||
# Convert dates to days since min_date for numerical scaling
|
||||
total_days = (max_x_dt - min_x_dt).days
|
||||
x_values_numeric = [(d - min_x_dt).days if d is not None else None for d in x_values_raw]
|
||||
min_x, max_x = 0, total_days
|
||||
else: # Numeric or Categorical (treat categorical index as numeric for now)
|
||||
valid_x = [x for x in x_values_raw if x is not None]
|
||||
if not valid_x:
|
||||
plot_data['plot_type'] = 'table'; return plot_data
|
||||
min_x, max_x = min(valid_x), max(valid_x)
|
||||
x_values_numeric = x_values_raw # Already numeric (or will be treated as such)
|
||||
|
||||
valid_y = [y for y in y_values_raw if y is not None]
|
||||
if not valid_y:
|
||||
plot_data['plot_type'] = 'table'; return plot_data
|
||||
min_y, max_y = min(valid_y), max(valid_y)
|
||||
|
||||
range_x = max_x - min_x
|
||||
range_y = max_y - min_y
|
||||
|
||||
# Scale points
|
||||
for i, row in enumerate(results):
|
||||
x_num = x_values_numeric[i]
|
||||
y_num = y_values_raw[i] # Use original list which might have None
|
||||
|
||||
if x_num is None or y_num is None: continue # Skip points with missing essential data
|
||||
|
||||
# Scale X to drawing width, Y to drawing height (inverted Y for SVG)
|
||||
scaled_x = margin['left'] + _normalize_value(x_num, min_x, range_x, draw_width)
|
||||
scaled_y = margin['top'] + draw_height - _normalize_value(y_num, min_y, range_y, draw_height)
|
||||
|
||||
points.append({
|
||||
'x': scaled_x,
|
||||
'y': scaled_y,
|
||||
'original': row # Store original row data for tooltips
|
||||
})
|
||||
|
||||
# --- Generate Ticks ---
|
||||
num_ticks = 5 # Desired number of ticks
|
||||
# X Ticks
|
||||
x_ticks = []
|
||||
if range_x >= 0:
|
||||
step_x = (max_x - min_x) / (num_ticks -1) if num_ticks > 1 and range_x > 0 else 0
|
||||
for i in range(num_ticks):
|
||||
tick_val_raw = min_x + i * step_x
|
||||
tick_pos = margin['left'] + _normalize_value(tick_val_raw, min_x, range_x, draw_width)
|
||||
label = ""
|
||||
if x_type == 'datetime':
|
||||
tick_date = min_x_dt + timedelta(days=tick_val_raw)
|
||||
label = tick_date.strftime('%Y-%m-%d') # Format date label
|
||||
else: # Numeric
|
||||
label = f"{tick_val_raw:.1f}" if isinstance(tick_val_raw, float) else str(tick_val_raw)
|
||||
|
||||
x_ticks.append({'value': tick_val_raw, 'label': label, 'position': tick_pos})
|
||||
|
||||
# Y Ticks
|
||||
y_ticks = []
|
||||
if range_y >= 0:
|
||||
step_y = (max_y - min_y) / (num_ticks - 1) if num_ticks > 1 and range_y > 0 else 0
|
||||
for i in range(num_ticks):
|
||||
tick_val = min_y + i * step_y
|
||||
tick_pos = margin['top'] + draw_height - _normalize_value(tick_val, min_y, range_y, draw_height)
|
||||
label = f"{tick_val:.1f}" if isinstance(tick_val, float) else str(tick_val)
|
||||
y_ticks.append({'value': tick_val, 'label': label, 'position': tick_pos})
|
||||
|
||||
|
||||
# --- Finalize Plot Data ---
|
||||
# For now, put all points into one series
|
||||
plot_data['plots'].append({
|
||||
'label': f'{y_col} vs {x_col}',
|
||||
'color': '#388fed', # Default color
|
||||
'points': points
|
||||
})
|
||||
plot_data['x_ticks'] = x_ticks
|
||||
plot_data['y_ticks'] = y_ticks
|
||||
|
||||
# Add specific adjustments for plot types if needed (e.g., bar width)
|
||||
if plot_type == 'bar':
|
||||
# Calculate bar width based on number of bars/categories
|
||||
# This needs more refinement based on how categorical X is handled
|
||||
plot_data['bar_width'] = draw_width / len(points) * 0.8 if points else 10
|
||||
|
||||
|
||||
return plot_data
|
||||
Reference in New Issue
Block a user