Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Stockings
dd82f461be feat: Add workout program management
- Create database tables: workout_program, program_session, person_program_assignment.
- Add Flask blueprint `routes/programs.py` with routes for creating, listing, viewing, and deleting programs.
- Implement program creation form (`templates/program_create.html`):
    - Allows defining program name, description, and multiple sessions.
    - Each session includes a name and dynamically added exercise selections.
    - Uses `tail.select` for searchable exercise dropdowns.
    - JavaScript handles dynamic addition/removal of sessions and exercises.
- Implement backend logic for program creation:
    - Parses form data including multiple exercises per session.
    - Automatically finds or creates non-person-specific tags based on selected exercises for each session.
    - Saves program and session data, linking sessions to appropriate tags.
- Implement program list view (`templates/program_list.html`):
    - Displays existing programs.
    - Includes HTMX-enabled delete button for each program.
    - Links program names to the view page using HTMX for dynamic loading.
- Implement program detail view (`templates/program_view.html`):
    - Displays program name, description, and sessions.
    - Parses session tag filters to retrieve and display associated exercises.
- Update changelog with details of the new feature.
2025-04-24 20:17:30 +10:00
Peter Stockings
e7d125d57b Move workout tag logic into tags blueprint 2025-04-21 20:13:30 +10:00
Peter Stockings
c88d28b47c Fix issue with newly added workout tags not being rendered 2025-04-20 17:05:32 +10:00
Peter Stockings
7aa7f9b8dc Partial refactor of tags functionality
Still need to move tags db logic to BP and move workout tag logic to BP as well
2025-04-19 21:10:34 +10:00
Peter Stockings
e947feb3e3 refactor(sql_explorer): Replace Plotly with SVG rendering for plots
Replaces the Plotly-based graph generation in the SQL Explorer with direct SVG rendering within an HTML template, similar to the exercise progress sparklines.

- Modifies `routes/sql_explorer.py` endpoints (`plot_query`, `plot_unsaved_query`) to fetch raw data instead of using pandas/Plotly.
- Adds `utils.prepare_svg_plot_data` to process raw SQL results, determine plot type (scatter, line, bar, table), normalize data, and prepare it for SVG.
- Creates `templates/partials/sql_explorer/svg_plot.html` to render the SVG plot with axes, ticks, labels, and basic tooltips.
- Removes the `generate_plot` function's usage for SQL Explorer and the direct dependency on Plotly for this feature.
2025-04-15 19:34:26 +10:00
Peter Stockings
51ec18c461 feat: Add dismissible exercise progress graph to workout page
Adds a feature to view exercise progress directly from the workout page.

- Modifies `templates/partials/topset.html`.
- Adds a graph icon next to the exercise name in the topset list.
- Clicking the icon uses HTMX to fetch and display the progress graph for that exercise inline in a new table row.
- Implements a dismiss button using hyperscript to hide the graph after viewing.
2025-04-13 19:20:53 +10:00
Peter Stockings
3da0dc3b3d Fix for regression where selecting exercise for a new set on an exercise that hasnt had an set recorded wouldnt diplay the name 2025-04-13 18:04:11 +10:00
Peter Stockings
62e203bc2a feat: Add SQL script export option
- Added functionality to export the full database schema (CREATE statements) and data (INSERT statements) as a single `.sql` file.
- Created a new route `/export/database.sql` in `routes/export.py`.
- Added helper functions to `routes/export.py` (adapted from `sql_explorer`) to generate schema CREATE statements and data INSERT statements.
- Added a download link for the SQL export to the Settings page (`templates/settings.html`).
- Updated the changelog entry for data export to include the SQL option.
2025-04-12 21:17:19 +10:00
Peter Stockings
2d67badd32 Remove comments from generated SQL queries 2025-04-05 21:51:56 +11:00
Peter Stockings
64dda01af6 Add on requests==2.26.0 to requirements.txt 2025-04-05 21:38:17 +11:00
23 changed files with 2135 additions and 352 deletions

37
app.py
View File

@@ -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.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.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.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 extensions import db
from utils import convert_str_to_date, generate_plot from utils import convert_str_to_date, generate_plot
from flask_htmx import HTMX 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(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(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(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 @app.after_request
def response_minify(response): def response_minify(response):
@@ -210,36 +216,7 @@ def settings():
return render_template('settings.html', people=people, exercises=exercises) return render_template('settings.html', people=people, exercises=exercises)
@ app.route("/tag/redirect", methods=['GET']) # Routes moved to routes/tags.py blueprint
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)
@ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET']) @ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET'])
def get_exercise_progress_for_user(person_id, exercise_id): def get_exercise_progress_for_user(person_id, exercise_id):

82
db.py
View File

@@ -341,88 +341,6 @@ class DataBase():
def delete_tag_for_dashboard(self, tag_id): def delete_tag_for_dashboard(self, tag_id):
self.execute('DELETE FROM Tag WHERE tag_id=%s', [tag_id], commit=True) 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): def get_workout_tags(self, person_id, workout_id):
person_tags = self.execute(""" person_tags = self.execute("""
SELECT SELECT

View File

@@ -1,13 +1,16 @@
import pandas as pd import pandas as pd
from utils import get_distinct_colors from utils import get_distinct_colors, calculate_estimated_1rm
class PeopleGraphs: class PeopleGraphs:
def __init__(self, db_connection_method): def __init__(self, db_connection_method):
self.execute = 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): 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 = """ query = """
SELECT SELECT
P.person_id AS "PersonId", P.person_id AS "PersonId",
@@ -18,22 +21,16 @@ class PeopleGraphs:
E.exercise_id AS "ExerciseId", E.exercise_id AS "ExerciseId",
E.name AS "ExerciseName", E.name AS "ExerciseName",
T.repetitions AS "Repetitions", T.repetitions AS "Repetitions",
T.weight AS "Weight", T.weight AS "Weight"
round((100 * T.Weight::numeric::integer)/(101.3-2.67123 * T.Repetitions),0)::numeric::integer AS "Estimated1RM"
FROM Person P FROM Person P
LEFT JOIN Workout W ON P.person_id = W.person_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 TopSet T ON W.workout_id = T.workout_id
LEFT JOIN Exercise E ON T.exercise_id = E.exercise_id LEFT JOIN Exercise E ON T.exercise_id = E.exercise_id
WHERE TRUE WHERE TRUE
""" """
# Parameters for the query
params = [] params = []
# Add optional filters
if selected_people_ids: if selected_people_ids:
placeholders = ", ".join(["%s"] * len(selected_people_ids)) query += f" AND P.person_id IN ({', '.join(['%s'] * len(selected_people_ids))})"
query += f" AND P.person_id IN ({placeholders})"
params.extend(selected_people_ids) params.extend(selected_people_ids)
if min_date: if min_date:
query += " AND W.start_date >= %s" query += " AND W.start_date >= %s"
@@ -42,143 +39,233 @@ class PeopleGraphs:
query += " AND W.start_date <= %s" query += " AND W.start_date <= %s"
params.append(max_date) params.append(max_date)
if selected_exercise_ids: if selected_exercise_ids:
placeholders = ", ".join(["%s"] * len(selected_exercise_ids)) query += f" AND E.exercise_id IN ({', '.join(['%s'] * len(selected_exercise_ids))})"
query += f" AND E.exercise_id IN ({placeholders})"
params.extend(selected_exercise_ids) params.extend(selected_exercise_ids)
# Execute the query # Execute and convert to DataFrame
topsets = self.execute(query, params) 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 df = pd.DataFrame(raw_data)
weekly_counts = self.get_workout_counts(topsets, 'week')
weekly_pr_counts = self.count_prs_over_time(topsets, 'week')
graphs = [self.get_weekly_pr_graph_model('Workouts per week', weekly_counts), self.get_weekly_pr_graph_model('PRs per week', weekly_pr_counts)] # Calculate Estimated1RM in Python
return graphs 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): # Build the weekly data models
# Assuming weekly_pr_data is in the format {1: {"PersonName": "Alice", "PRCounts": {Timestamp('2022-01-01', freq='W-MON'): 0, ...}}, 2: {...}, ...} 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 return [
all_dates = [date for user_data in weekly_pr_data.values() for date in user_data["PRCounts"].keys()] self.get_graph_model("Workouts per week", weekly_counts),
min_date, max_date = min(all_dates), max(all_dates) self.get_graph_model("PRs per week", weekly_pr_counts)
total_span = (max_date - min_date).days or 1 ]
relative_positions = [(date - min_date).days / total_span for date in all_dates]
# Calculate viewBox dimensions def _prepare_period_column(self, df, period='week'):
max_value = max(max(user_data["PRCounts"].values()) for user_data in weekly_pr_data.values()) or 1 """
min_value = 0 Convert StartDate to datetime and add a Period column
value_range = max_value - min_value based on 'week' or 'month' as needed.
vb_width = 200 """
vb_height= 75 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 = [] def get_workout_counts(self, df, period='week'):
colors = get_distinct_colors(len(weekly_pr_data.items())) """
for count, (user_id, user_data) in enumerate(weekly_pr_data.items()): Returns a dictionary:
pr_counts = user_data["PRCounts"] {
person_name = user_data["PersonName"] person_id: {
'PersonName': 'Alice',
values = pr_counts.values() 'PRCounts': {
Timestamp('2023-01-02'): 2,
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)
# 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
} }
plots.append(plot) },
...
}
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)
# 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)
return self._pivot_to_graph_dict(
grp,
index_col='PersonId',
name_col='PersonName',
period_col='Period',
value_col='Count'
)
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
}
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 workout data with SVG dimensions and data points
return { return {
'title': title, 'title': title,
'vb_width': vb_width, 'vb_width': vb_width,
'vb_height': vb_height, 'vb_height': vb_height,
'plots': plots '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

View File

@@ -17,3 +17,4 @@ flask-wtf==1.2.2
Flask-Login==0.6.3 Flask-Login==0.6.3
Flask-Bcrypt==1.0.1 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
View 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
View 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

View File

@@ -5,7 +5,7 @@ from flask import Blueprint, render_template, request, current_app, jsonify
from jinja2_fragments import render_block from jinja2_fragments import render_block
from flask_htmx import HTMX from flask_htmx import HTMX
from extensions import db 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') sql_explorer_bp = Blueprint('sql_explorer', __name__, url_prefix='/sql')
htmx = HTMX() htmx = HTMX()
@@ -216,7 +216,12 @@ Return ONLY the SQL query, without any explanation or surrounding text/markdown.
if generated_sql.endswith("```"): if generated_sql.endswith("```"):
generated_sql = generated_sql[:-3] 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: except requests.exceptions.RequestException as e:
current_app.logger.error(f"Gemini API request error: {e}") current_app.logger.error(f"Gemini API request error: {e}")
@@ -276,17 +281,47 @@ def sql_schema():
def plot_query(query_id): def plot_query(query_id):
(title, query) = _get_saved_query(query_id) (title, query) = _get_saved_query(query_id)
if not query: return "Query not found", 404 if not query: return "Query not found", 404
results_df = db.read_sql_as_df(query) # Fetch raw results instead of DataFrame
plot_div = generate_plot(results_df, title) (results, columns, error) = _execute_sql(query)
return plot_div if error:
# Return an HTML snippet indicating the error
return f'&lt;div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded"&gt;Error executing query: {error}&lt;/div&gt;', 400
if not results:
# Return an HTML snippet indicating no data
return '&lt;div class="p-4 text-yellow-700 bg-yellow-100 border border-yellow-400 rounded"&gt;No data returned by query.&lt;/div&gt;'
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'&lt;div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded"&gt;Error preparing plot data: {e}&lt;/div&gt;', 500
@sql_explorer_bp.route("/plot/show", methods=['POST']) @sql_explorer_bp.route("/plot/show", methods=['POST'])
def plot_unsaved_query(): def plot_unsaved_query():
query = request.form.get('query') query = request.form.get('query')
title = request.form.get('title') title = request.form.get('title', 'SQL Query Plot') # Add default title
results_df = db.read_sql_as_df(query) # Fetch raw results instead of DataFrame
plot_div = generate_plot(results_df, title) (results, columns, error) = _execute_sql(query)
return plot_div if error:
# Return an HTML snippet indicating the error
return f'&lt;div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded"&gt;Error executing query: {error}&lt;/div&gt;', 400
if not results:
# Return an HTML snippet indicating no data
return '&lt;div class="p-4 text-yellow-700 bg-yellow-100 border border-yellow-400 rounded"&gt;No data returned by query.&lt;/div&gt;'
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'&lt;div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded"&gt;Error preparing plot data: {e}&lt;/div&gt;', 500
@sql_explorer_bp.route("/generate_sql", methods=['POST']) @sql_explorer_bp.route("/generate_sql", methods=['POST'])
def generate_sql(): def generate_sql():

263
routes/tags.py Normal file
View 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)

View File

@@ -208,18 +208,6 @@ def delete_topset(person_id, workout_id, topset_id):
db.delete_topset(topset_id) db.delete_topset(topset_id)
return "" 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']) @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): def get_most_recent_topset_for_exercise(person_id, workout_id):
exercise_id = request.args.get('exercise_id', type=int) 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) 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 topset = db.get_most_recent_topset_for_exercise(person_id, exercise_id) # Keep using db.py for now
if not topset: 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 (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) 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)

View File

@@ -20,7 +20,6 @@
<script src="/static/js/sweetalert2@11.js" defer></script> <script src="/static/js/sweetalert2@11.js" defer></script>
<!-- Mermaid --> <!-- Mermaid -->
<script src="/static/js/mermaid.min.js"></script> <script src="/static/js/mermaid.min.js"></script>
<script src="/static/js/plotly-2.35.2.min.js" defer></script>
<script> <script>
// Initialize Mermaid with startOnLoad set to false // Initialize Mermaid with startOnLoad set to false
mermaid.initialize({ mermaid.initialize({
@@ -156,6 +155,25 @@
</div> </div>
</ul> </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"> <div class="space-y-2 pt-2">
<a hx-get="{{ url_for('sql_explorer.sql_explorer') }}" hx-push-url="true" <a hx-get="{{ url_for('sql_explorer.sql_explorer') }}" hx-push-url="true"
hx-target="#container" hx-target="#container"

View File

@@ -10,6 +10,85 @@
<div class="prose max-w-none"> <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> <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 --> <!-- New Entry for SQL Generation -->
<hr class="my-6"> <hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 5, 2025</h2> <h2 class="text-xl font-semibold mb-2">April 5, 2025</h2>

View File

@@ -92,7 +92,9 @@
</div> </div>
</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>
<div class="hidden" hx-get="{{ url_for('get_people_graphs') }}" <div class="hidden" hx-get="{{ url_for('get_people_graphs') }}"
@@ -160,7 +162,7 @@
<tbody class="bg-white"> <tbody class="bg-white">
{% for set in exercise.sets %} {% 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-vals='{"filter": "?exercise_id={{ set.exercise_id }}", "person_id" : "{{ person.id }}" }'
hx-target="#container" hx-swap="innerHTML" hx-push-url="true" hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
class="cursor-pointer"> class="cursor-pointer">

View 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>

View File

@@ -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 %} {% for t in tags %}
<div data-te-chip-init data-te-ripple-init <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"> data-te-ripple-color="dark">
<span hx-get="{{ url_for('goto_tag') }}" {% if person_id %} {# Tag Name (Clickable to filter) #}
hx-vals='{"filter": "{{ t["tag_filter"] }}", "person_id": "{{ person_id }}"}' {% else %} <span class="pr-2" hx-get="{{ url_for('tags.goto_tag') }}"
hx-vals='{"filter": "{{ t["tag_filter"] }}"}' {% endif%} hx-target="#container" hx-push-url="true">{{ hx-vals='{"filter": "{{ t.tag_filter }}", "person_id": "{{ person_id | default("", true) }}"}'
t['tag_name'] }}</span> hx-target="#container" hx-push-url="true">{{ t.tag_name }}</span>
{# Delete Button #}
<span <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" class="ml-1 cursor-pointer text-gray-400 hover:text-gray-600 dark:text-neutral-400 dark:hover:text-neutral-100"
hx-get="{{ url_for('delete_tag', tag_id=t['tag_id']) }}" {% if person_id %} hx-delete="{{ url_for('tags.delete_tag', tag_id=t.tag_id) }}" hx-target="#tags-container" {# Target the
hx-vals='{"filter": "{{ t["tag_filter"] }}", "person_id": "{{ person_id }}"}' {% else %} container to refresh the list #} hx-swap="outerHTML" {# Replace the whole container #}
hx-vals='{"filter": "{{ t["tag_filter"] }}"}' {% endif%} hx-target="#container" hx-push-url="true" _="on htmx:confirm(issueRequest) hx-confirm="Are you sure you want to delete the '{{ t.tag_name }}' tag?"
halt the event hx-vals='{"person_id": "{{ person_id | default("", true) }}", "current_filter": "{{ request.query_string | default("", true) }}"}'>
call Swal.fire({title: 'Confirm', text:'Are you sure you want to delete {{ t['tag_name'] }} tag?'}) {# Pass context if needed by backend #}
if result.isConfirmed issueRequest()">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" <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"> stroke="currentColor" class="h-3 w-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -26,52 +27,44 @@
</div> </div>
{% endfor %} {% endfor %}
<div class="flex justify-center space-x-2"> {# Add Tag Section - Initially Hidden Button, reveals Form #}
<div> <div id="add-tag-section" class="my-1">
<button type="button" data-te-ripple-init data-te-ripple-color="light" {# Show Add Button #}
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)]" <button id="show-add-tag-form-btn"
id="add-tag"> 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" <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"> stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
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> </svg>
</button> </button>
</div>
</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')
}
})
}) {# Add Tag Form (Initially Hidden) #}
</script> <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>
<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>

View File

@@ -1,11 +1,24 @@
<tr id="topset-{{ topset_id }}"> <tr id="topset-{{ topset_id }}">
<td class="p-0 sm:p-4 text-sm font-semibold text-gray-900 break-normal"> <td class="p-0 sm:p-4 text-sm font-semibold text-gray-900 break-normal">
{% if is_edit|default(false, true) == false %} {% if is_edit|default(false, true) == false %}
<span class="cursor-pointer" hx-get="{{ url_for('goto_tag') }}" <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-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
hx-target="#container" hx-swap="innerHTML" hx-push-url="true" hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name _='on click trigger closeModalWithoutRefresh'>{{ exercise_name }}</span>
}}</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 %} {% else %}
<div class="w-full"> <div class="w-full">
<select name="exercise_id" <select name="exercise_id"
@@ -91,3 +104,33 @@
</div> </div>
</td> </td>
</tr> </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>

View File

@@ -35,7 +35,7 @@
<div class="relative"> <div class="relative">
<div class="w-full"> <div class="w-full">
<select multiple name="tag_id" <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 }}" 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" 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) _="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" 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"> type="text" name="tag_name">
<button type="submit" <button type="submit" hx-post="{{ url_for('tags.create_new_tag_for_workout', workout_id=workout_id) }}"
hx-post="{{ url_for('workout.create_new_tag_for_workout', person_id=person_id, workout_id=workout_id) }}"
hx-include="[name='tag_name']" hx-target="#tag-wrapper-w-{{ 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"> 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" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"

View File

@@ -1,10 +1,11 @@
{% if tags|length == 0 %} {% if tags|length > 0 %}
{% for tag in tags %} {% for tag in tags %}
{% if tag.is_selected %} {% if tag.is_selected %}
<span <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" 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-get="{{ url_for('tags.goto_tag') }}"
hx-target="#container" hx-push-url="true"> hx-vals='{"filter": "{{ tag.tag_filter }}", "person_id": "{{ tag.person_id }}"}' hx-target="#container"
hx-push-url="true">
{{ tag.tag_name }} {{ tag.tag_name }}
</span> </span>
{% endif %} {% endif %}

View File

@@ -92,7 +92,9 @@
</div> </div>
</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') }}" <div class="hidden" hx-get="{{ url_for('get_people_graphs') }}"
hx-include="[name='exercise_id'],[name='min_date'],[name='max_date']" hx-include="[name='exercise_id'],[name='min_date'],[name='max_date']"

View 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 %}

View 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 %}

View 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">
&larr; 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 %}

View File

@@ -182,6 +182,39 @@
</div> </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> </div>
{% endblock %} {% endblock %}

228
utils.py
View File

@@ -3,7 +3,9 @@ from datetime import datetime, date, timedelta
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import plotly.express as px 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'): def convert_str_to_date(date_str, format='%Y-%m-%d'):
try: try:
@@ -142,3 +144,227 @@ def calculate_estimated_1rm(weight, repetitions):
return 0 return 0
estimated_1rm = round((100 * int(weight)) / (101.3 - 2.67123 * repetitions), 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