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.sql_explorer import sql_explorer_bp # Import the new SQL explorer blueprint
from routes.endpoints import endpoints_bp # Import the new endpoints blueprint
from routes.export import export_bp # Import the new export blueprint
from routes.tags import tags_bp # Import the new tags blueprint
from routes.programs import programs_bp # Import the new programs blueprint
from extensions import db
from utils import convert_str_to_date, generate_plot
from flask_htmx import HTMX
@@ -44,6 +47,9 @@ app.register_blueprint(notes_bp) # Register the notes blueprint
app.register_blueprint(workout_bp) # Register the workout blueprint
app.register_blueprint(sql_explorer_bp) # Register the SQL explorer blueprint (prefix defined in blueprint file)
app.register_blueprint(endpoints_bp) # Register the endpoints blueprint (prefix defined in blueprint file)
app.register_blueprint(export_bp) # Register the export blueprint (prefix defined in blueprint file)
app.register_blueprint(tags_bp) # Register the tags blueprint (prefix defined in blueprint file)
app.register_blueprint(programs_bp) # Register the programs blueprint (prefix defined in blueprint file)
@app.after_request
def response_minify(response):
@@ -210,36 +216,7 @@ def settings():
return render_template('settings.html', people=people, exercises=exercises)
@ app.route("/tag/redirect", methods=['GET'])
def goto_tag():
person_id = request.args.get("person_id")
tag_filter = request.args.get('filter')
if person_id:
return redirect(url_for('person_overview', person_id=int(person_id)) + tag_filter)
return redirect(url_for('dashboard') + tag_filter)
@ app.route("/tag/add", methods=['GET'])
def add_tag():
person_id = request.args.get("person_id")
tag = request.args.get('tag')
tag_filter = request.args.get('filter')
if person_id:
db.add_or_update_tag_for_person(person_id, tag, tag_filter)
else:
db.add_or_update_tag_for_dashboard(tag, tag_filter)
return ""
@ app.route("/tag/<int:tag_id>/delete", methods=['GET'])
def delete_tag(tag_id):
person_id = request.args.get("person_id")
tag_filter = request.args.get("filter")
if person_id:
db.delete_tag_for_person(person_id=person_id, tag_id=tag_id)
return redirect(url_for('get_person', person_id=person_id) + tag_filter)
db.delete_tag_for_dashboard(tag_id)
return redirect(url_for('dashboard') + tag_filter)
# Routes moved to routes/tags.py blueprint
@ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET'])
def get_exercise_progress_for_user(person_id, exercise_id):

82
db.py
View File

@@ -341,88 +341,6 @@ class DataBase():
def delete_tag_for_dashboard(self, tag_id):
self.execute('DELETE FROM Tag WHERE tag_id=%s', [tag_id], commit=True)
# Note update logic moved to routes/notes.py
def add_tag_for_workout(self, workout_id, tags_id):
# If tags_id is not empty, delete tags that are not in the new selection
if tags_id:
self.execute(
"""
DELETE FROM workout_tag
WHERE workout_id = %s AND tag_id NOT IN %s
""",
[workout_id, tuple(tags_id)], commit=True
)
else:
# If tags_id is empty, delete all tags for this workout
self.execute(
"""
DELETE FROM workout_tag
WHERE workout_id = %s
""",
[workout_id], commit=True
)
# Then, attempt to insert the new tags
for tag_id in tags_id:
self.execute(
"""
INSERT INTO workout_tag (workout_id, tag_id)
VALUES (%s, %s)
ON CONFLICT (workout_id, tag_id) DO NOTHING
""",
[workout_id, tag_id], commit=True
)
# Now fetch updated list of workout tags
workout_tags = self.execute("""
SELECT
T.tag_id AS "tag_id",
T.person_id AS "person_id",
T.name AS "tag_name",
T.filter AS "tag_filter",
TRUE AS "is_selected"
FROM Workout_Tag WT
LEFT JOIN Tag T ON WT.tag_id=T.tag_id
WHERE WT.workout_id=%s""", [workout_id])
return workout_tags
def create_tag_for_workout(self, person_id, workout_id, tag_name):
workout_exercises = self.execute("""
SELECT
E.exercise_id AS "exercise_id",
E.name AS "exercise_name"
FROM Workout W
LEFT JOIN TopSet T ON W.workout_id=T.workout_id
LEFT JOIN Exercise E ON T.exercise_id=E.exercise_id
WHERE W.workout_id=%s""", [workout_id])
tag_filter = "?" + \
"&".join(
f"exercise_id={e['exercise_id']}" for e in workout_exercises)
# create tag for person
row = self.execute('INSERT INTO Tag (person_id, name, filter) VALUES (%s, %s, %s) RETURNING tag_id AS "tag_id"', [
person_id, tag_name, tag_filter], commit=True, one=True)
# add tag to workout
self.execute('INSERT INTO Workout_Tag (workout_id, tag_id) VALUES (%s, %s)', [
workout_id, row['tag_id']], commit=True)
# Now fetch updated list of workout tags
workout_tags = self.execute("""
SELECT
T.tag_id AS "tag_id",
T.person_id AS "person_id",
T.name AS "tag_name",
T.filter AS "tag_filter"
FROM Workout_Tag WT
LEFT JOIN Tag T ON WT.tag_id=T.tag_id
WHERE WT.workout_id=%s""", [workout_id])
return workout_tags
def get_workout_tags(self, person_id, workout_id):
person_tags = self.execute("""
SELECT

View File

@@ -1,39 +1,36 @@
import pandas as pd
from utils import get_distinct_colors
from utils import get_distinct_colors, calculate_estimated_1rm
class PeopleGraphs:
def __init__(self, db_connection_method):
self.execute = db_connection_method
def get(self, selected_people_ids=None, min_date=None, max_date=None, selected_exercise_ids=None):
# Base query
"""
Fetch workout topsets, calculate Estimated1RM in Python,
then generate weekly workout & PR graphs.
"""
# Build query (no in-SQL 1RM calculation).
query = """
SELECT
P.person_id AS "PersonId",
P.name AS "PersonName",
W.workout_id AS "WorkoutId",
W.start_date AS "StartDate",
T.topset_id AS "TopSetId",
P.person_id AS "PersonId",
P.name AS "PersonName",
W.workout_id AS "WorkoutId",
W.start_date AS "StartDate",
T.topset_id AS "TopSetId",
E.exercise_id AS "ExerciseId",
E.name AS "ExerciseName",
E.name AS "ExerciseName",
T.repetitions AS "Repetitions",
T.weight AS "Weight",
round((100 * T.Weight::numeric::integer)/(101.3-2.67123 * T.Repetitions),0)::numeric::integer AS "Estimated1RM"
T.weight AS "Weight"
FROM Person P
LEFT JOIN Workout W ON P.person_id = W.person_id
LEFT JOIN TopSet T ON W.workout_id = T.workout_id
LEFT JOIN Exercise E ON T.exercise_id = E.exercise_id
LEFT JOIN Workout W ON P.person_id = W.person_id
LEFT JOIN TopSet T ON W.workout_id = T.workout_id
LEFT JOIN Exercise E ON T.exercise_id = E.exercise_id
WHERE TRUE
"""
# Parameters for the query
params = []
# Add optional filters
if selected_people_ids:
placeholders = ", ".join(["%s"] * len(selected_people_ids))
query += f" AND P.person_id IN ({placeholders})"
query += f" AND P.person_id IN ({', '.join(['%s'] * len(selected_people_ids))})"
params.extend(selected_people_ids)
if min_date:
query += " AND W.start_date >= %s"
@@ -42,143 +39,233 @@ class PeopleGraphs:
query += " AND W.start_date <= %s"
params.append(max_date)
if selected_exercise_ids:
placeholders = ", ".join(["%s"] * len(selected_exercise_ids))
query += f" AND E.exercise_id IN ({placeholders})"
query += f" AND E.exercise_id IN ({', '.join(['%s'] * len(selected_exercise_ids))})"
params.extend(selected_exercise_ids)
# Execute the query
topsets = self.execute(query, params)
# Execute and convert to DataFrame
raw_data = self.execute(query, params)
if not raw_data:
# Return empty graphs if no data at all
return [
self.get_graph_model("Workouts per week", {}),
self.get_graph_model("PRs per week", {})
]
# Generate graphs
weekly_counts = self.get_workout_counts(topsets, 'week')
weekly_pr_counts = self.count_prs_over_time(topsets, 'week')
df = pd.DataFrame(raw_data)
graphs = [self.get_weekly_pr_graph_model('Workouts per week', weekly_counts), self.get_weekly_pr_graph_model('PRs per week', weekly_pr_counts)]
return graphs
# Calculate Estimated1RM in Python
df['Estimated1RM'] = df.apply(
lambda row: calculate_estimated_1rm(row["Weight"], row["Repetitions"]), axis=1
)
def get_weekly_pr_graph_model(self, title, weekly_pr_data):
# Assuming weekly_pr_data is in the format {1: {"PersonName": "Alice", "PRCounts": {Timestamp('2022-01-01', freq='W-MON'): 0, ...}}, 2: {...}, ...}
# Build the weekly data models
weekly_counts = self.get_workout_counts(df, period='week')
weekly_pr_counts = self.count_prs_over_time(df, period='week')
# Find the overall date range for all users
all_dates = [date for user_data in weekly_pr_data.values() for date in user_data["PRCounts"].keys()]
min_date, max_date = min(all_dates), max(all_dates)
total_span = (max_date - min_date).days or 1
relative_positions = [(date - min_date).days / total_span for date in all_dates]
return [
self.get_graph_model("Workouts per week", weekly_counts),
self.get_graph_model("PRs per week", weekly_pr_counts)
]
# Calculate viewBox dimensions
max_value = max(max(user_data["PRCounts"].values()) for user_data in weekly_pr_data.values()) or 1
min_value = 0
value_range = max_value - min_value
vb_width = 200
vb_height= 75
def _prepare_period_column(self, df, period='week'):
"""
Convert StartDate to datetime and add a Period column
based on 'week' or 'month' as needed.
"""
df['StartDate'] = pd.to_datetime(df['StartDate'], errors='coerce')
freq = 'W' if period == 'week' else 'M'
df['Period'] = df['StartDate'].dt.to_period(freq)
return df
plots = []
colors = get_distinct_colors(len(weekly_pr_data.items()))
for count, (user_id, user_data) in enumerate(weekly_pr_data.items()):
pr_counts = user_data["PRCounts"]
person_name = user_data["PersonName"]
def get_workout_counts(self, df, period='week'):
"""
Returns a dictionary:
{
person_id: {
'PersonName': 'Alice',
'PRCounts': {
Timestamp('2023-01-02'): 2,
...
}
},
...
}
representing how many workouts each person performed per time period.
"""
# Make a copy and prepare Period column
df = self._prepare_period_column(df.copy(), period)
values = pr_counts.values()
# Count unique workouts per (PersonId, PersonName, Period)
grp = (
df.groupby(['PersonId', 'PersonName', 'Period'], as_index=False)['WorkoutId']
.nunique()
.rename(columns={'WorkoutId': 'Count'})
)
# Convert each Period to its start time
grp['Period'] = grp['Period'].apply(lambda p: p.start_time)
values_scaled = [((value - min_value) / value_range) * vb_height for value in values]
plot_points = list(zip(values_scaled, relative_positions))
messages = [f'{value} for {person_name} at {date.strftime("%d %b %y")}' for value, date in zip(values, pr_counts.keys())]
plot_labels = zip(values_scaled, relative_positions, messages)
return self._pivot_to_graph_dict(
grp,
index_col='PersonId',
name_col='PersonName',
period_col='Period',
value_col='Count'
)
# Create a plot for each user
plot = {
'label': person_name, # Use PersonName instead of User ID
'color': colors[count],
'points': plot_points,
'plot_labels': plot_labels
def count_prs_over_time(self, df, period='week'):
"""
Returns a dictionary:
{
person_id: {
'PersonName': 'Alice',
'PRCounts': {
Timestamp('2023-01-02'): 1,
...
}
},
...
}
representing how many PRs each person hit per time period.
"""
# Make a copy and prepare Period column
df = self._prepare_period_column(df.copy(), period)
# Max 1RM per (Person, Exercise, Period)
grouped = (
df.groupby(['PersonId', 'PersonName', 'ExerciseId', 'Period'], as_index=False)['Estimated1RM']
.max()
.rename(columns={'Estimated1RM': 'PeriodMax'})
)
# Sort so we can track "all-time max" up to that row
grouped.sort_values(by=['PersonId', 'ExerciseId', 'Period'], inplace=True)
# For each person & exercise, track the cumulative max (shifted by 1)
grouped['AllTimeMax'] = grouped.groupby(['PersonId', 'ExerciseId'])['PeriodMax'].cummax().shift(1)
grouped['IsPR'] = (grouped['PeriodMax'] > grouped['AllTimeMax']).astype(int)
# Sum PRs across exercises for (Person, Period)
pr_counts = (
grouped.groupby(['PersonId', 'PersonName', 'Period'], as_index=False)['IsPR']
.sum()
.rename(columns={'IsPR': 'Count'})
)
pr_counts['Period'] = pr_counts['Period'].apply(lambda p: p.start_time)
return self._pivot_to_graph_dict(
pr_counts,
index_col='PersonId',
name_col='PersonName',
period_col='Period',
value_col='Count'
)
def _pivot_to_graph_dict(self, df, index_col, name_col, period_col, value_col):
"""
Convert [index_col, name_col, period_col, value_col]
into a nested dictionary for plotting:
{
person_id: {
'PersonName': <...>,
'PRCounts': {
<timestamp>: <value>,
...
}
},
...
}
"""
if df.empty:
return {}
pivoted = df.pivot(
index=[index_col, name_col],
columns=period_col,
values=value_col
).fillna(0)
pivoted.reset_index(inplace=True)
result = {}
for _, row in pivoted.iterrows():
pid = row[index_col]
pname = row[name_col]
# Remaining columns = date -> count
period_counts = row.drop([index_col, name_col]).to_dict()
result[pid] = {
'PersonName': pname,
'PRCounts': period_counts
}
plots.append(plot)
# Return workout data with SVG dimensions and data points
return result
def get_graph_model(self, title, data_dict):
"""
Builds a line-graph model from a dictionary of the form:
{
person_id: {
'PersonName': 'Alice',
'PRCounts': {
Timestamp('2023-01-02'): 2,
Timestamp('2023-01-09'): 1,
...
}
},
...
}
"""
if not data_dict:
return {
'title': title,
'vb_width': 200,
'vb_height': 75,
'plots': []
}
# Gather all dates & values
all_dates = []
all_values = []
for user_data in data_dict.values():
all_dates.extend(user_data['PRCounts'].keys())
all_values.extend(user_data['PRCounts'].values())
min_date = min(all_dates)
max_date = max(all_dates)
date_span = max((max_date - min_date).days, 1)
max_val = max(all_values)
min_val = 0
val_range = max_val - min_val if max_val != min_val else 1
vb_width, vb_height = 200, 75
colors = get_distinct_colors(len(data_dict))
plots = []
for i, (pid, user_data) in enumerate(data_dict.items()):
name = user_data['PersonName']
pr_counts = user_data['PRCounts']
# Sort by date so points are in chronological order
sorted_pr = sorted(pr_counts.items(), key=lambda x: x[0])
points = []
labels = []
for d, val in sorted_pr:
# Scale x,y to fit [0..1], then we multiply y by vb_height
x = (d - min_date).days / date_span
y = (val - min_val) / val_range * vb_height
points.append((y, x))
labels.append((y, x, f'{val} for {name} at {d.strftime("%d %b %y")}'))
plots.append({
'label': name,
'color': colors[i],
'points': points,
'plot_labels': labels
})
return {
'title': title,
'vb_width': vb_width,
'vb_height': vb_height,
'plots': plots
}
def get_workout_counts(self, workouts, period='week'):
df = pd.DataFrame(workouts)
# Convert 'StartDate' to datetime and set period
df['StartDate'] = pd.to_datetime(df['StartDate'])
df['Period'] = df['StartDate'].dt.to_period('W' if period == 'week' else 'M')
# Group by PersonId, Period and count unique workouts
workout_counts = df.groupby(['PersonId', 'Period'])['WorkoutId'].nunique().reset_index()
# Convert 'Period' to timestamp using the start date of the period
workout_counts['Period'] = workout_counts['Period'].apply(lambda x: x.start_time)
# Pivot the result to get periods as columns
workout_counts_pivot = workout_counts.pivot(index='PersonId', columns='Period', values='WorkoutId').fillna(0)
# Include person names
names = df[['PersonId', 'PersonName']].drop_duplicates().set_index('PersonId')
workout_counts_final = names.join(workout_counts_pivot, how='left').fillna(0)
# Convert DataFrame to dictionary
result = workout_counts_final.reset_index().to_dict('records')
# Reformat the dictionary to desired structure
formatted_result = {}
for record in result:
person_id = record.pop('PersonId')
person_name = record.pop('PersonName')
pr_counts = {k: v for k, v in record.items()}
formatted_result[person_id] = {'PersonName': person_name, 'PRCounts': pr_counts}
return formatted_result
def count_prs_over_time(self, workouts, period='week'):
df = pd.DataFrame(workouts)
# Convert 'StartDate' to datetime
df['StartDate'] = pd.to_datetime(df['StartDate'])
# Set period as week or month
df['Period'] = df['StartDate'].dt.to_period('W' if period == 'week' else 'M')
# Group by Person, Exercise, and Period to find max Estimated1RM in each period
period_max = df.groupby(['PersonId', 'ExerciseId', 'Period'])['Estimated1RM'].max().reset_index()
# Determine all-time max Estimated1RM up to the start of each period
period_max['AllTimeMax'] = period_max.groupby(['PersonId', 'ExerciseId'])['Estimated1RM'].cummax().shift(1)
# Identify PRs as entries where the period's max Estimated1RM exceeds the all-time max
period_max['IsPR'] = period_max['Estimated1RM'] > period_max['AllTimeMax']
# Count PRs in each period for each person
pr_counts = period_max.groupby(['PersonId', 'Period'])['IsPR'].sum().reset_index()
# Convert 'Period' to timestamp using the start date of the period
pr_counts['Period'] = pr_counts['Period'].apply(lambda x: x.start_time)
# Pivot table to get the desired output format
output = pr_counts.pivot(index='PersonId', columns='Period', values='IsPR').fillna(0)
# Convert only the PR count columns to integers
for col in output.columns:
output[col] = output[col].astype(int)
# Merge with names and convert to desired format
names = df[['PersonId', 'PersonName']].drop_duplicates().set_index('PersonId')
output = names.join(output, how='left').fillna(0)
# Reset the index to bring 'PersonId' back as a column
output.reset_index(inplace=True)
# Convert to the final dictionary format with PRCounts nested
result = {}
for index, row in output.iterrows():
person_id = row['PersonId']
person_name = row['PersonName']
pr_counts = row.drop(['PersonId', 'PersonName']).to_dict()
result[person_id] = {"PersonName": person_name, "PRCounts": pr_counts}
return result

View File

@@ -16,4 +16,5 @@ wtforms==3.2.1
flask-wtf==1.2.2
Flask-Login==0.6.3
Flask-Bcrypt==1.0.1
email-validator==2.2.0
email-validator==2.2.0
requests==2.26.0

236
routes/export.py Normal file
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 flask_htmx import HTMX
from extensions import db
from utils import generate_plot
from utils import prepare_svg_plot_data # Will be created for SVG data prep
sql_explorer_bp = Blueprint('sql_explorer', __name__, url_prefix='/sql')
htmx = HTMX()
@@ -216,7 +216,12 @@ Return ONLY the SQL query, without any explanation or surrounding text/markdown.
if generated_sql.endswith("```"):
generated_sql = generated_sql[:-3]
return generated_sql.strip(), None
# Remove leading SQL comment lines
sql_lines = generated_sql.strip().splitlines()
filtered_lines = [line for line in sql_lines if not line.strip().startswith('--')]
final_sql = "\n".join(filtered_lines).strip()
return final_sql, None
except requests.exceptions.RequestException as e:
current_app.logger.error(f"Gemini API request error: {e}")
@@ -276,17 +281,47 @@ def sql_schema():
def plot_query(query_id):
(title, query) = _get_saved_query(query_id)
if not query: return "Query not found", 404
results_df = db.read_sql_as_df(query)
plot_div = generate_plot(results_df, title)
return plot_div
# Fetch raw results instead of DataFrame
(results, columns, error) = _execute_sql(query)
if error:
# Return an HTML snippet indicating the error
return f'&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'])
def plot_unsaved_query():
query = request.form.get('query')
title = request.form.get('title')
results_df = db.read_sql_as_df(query)
plot_div = generate_plot(results_df, title)
return plot_div
title = request.form.get('title', 'SQL Query Plot') # Add default title
# Fetch raw results instead of DataFrame
(results, columns, error) = _execute_sql(query)
if error:
# Return an HTML snippet indicating the error
return f'&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'])
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)
return ""
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/tag/add", methods=['POST'])
def add_tag_to_workout(person_id, workout_id):
tags_id = [int(i) for i in request.form.getlist('tag_id')]
tags = db.add_tag_for_workout(workout_id, tags_id) # Keep using db.py for complex tag logic for now
return render_template('partials/workout_tags_list.html', tags=tags)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/tag/new", methods=['POST'])
def create_new_tag_for_workout(person_id, workout_id):
tag_name = request.form.get('tag_name')
workout_tags = db.create_tag_for_workout(person_id, workout_id, tag_name) # Keep using db.py for complex tag logic for now
return render_template('partials/workout_tags_list.html', workout_tags=workout_tags)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])
def get_most_recent_topset_for_exercise(person_id, workout_id):
exercise_id = request.args.get('exercise_id', type=int)
@@ -228,7 +216,8 @@ def get_most_recent_topset_for_exercise(person_id, workout_id):
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises)
topset = db.get_most_recent_topset_for_exercise(person_id, exercise_id) # Keep using db.py for now
if not topset:
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, exercise_id=exercise_id)
exercise = db.execute("select name from exercise where exercise_id=%s", [exercise_id], one=True)
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, exercise_id=exercise_id, exercise_name=exercise['name'])
(repetitions, weight, exercise_name) = topset
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercise_id=exercise_id, exercise_name=exercise_name, repetitions=repetitions, weight=weight)

View File

@@ -20,7 +20,6 @@
<script src="/static/js/sweetalert2@11.js" defer></script>
<!-- Mermaid -->
<script src="/static/js/mermaid.min.js"></script>
<script src="/static/js/plotly-2.35.2.min.js" defer></script>
<script>
// Initialize Mermaid with startOnLoad set to false
mermaid.initialize({
@@ -156,6 +155,25 @@
</div>
</ul>
<div class="space-y-2 pt-2">
<a hx-get="{{ url_for('programs.list_programs') }}" hx-push-url="true"
hx-target="#container"
class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 group transition duration-75 flex items-center p-2 cursor-pointer {{ is_selected_page(url_for('sql_explorer.sql_explorer')) }} page-link"
_="on click add .hidden to #sidebar then remove .ml-64 from #main
on htmx:afterRequest go to the top of the body">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
viewBox="0 0 24 24" fill="currentColor">
<path
d="M6 5v14h3v-6h6v6h3V5h-3v6H9V5zM3 15a1 1 0 0 0 1 1h1V8H4a1 1 0 0 0-1 1v2H2v2h1v2zm18-6a1 1 0 0 0-1-1h-1v8h1a1 1 0 0 0 1-1v-2h1v-2h-1V9z">
</path>
</svg>
<span class="ml-3">Programs</span>
</a>
</div>
<div class="space-y-2 pt-2">
<a hx-get="{{ url_for('sql_explorer.sql_explorer') }}" hx-push-url="true"
hx-target="#container"

View File

@@ -10,6 +10,85 @@
<div class="prose max-w-none">
<p>Updates and changes to the site will be documented here, with the most recent changes listed first.</p>
<!-- New Entry for Workout Programs -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 24, 2025</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Added Workout Program Management:</li>
<ul class="list-disc pl-5 space-y-1">
<li>Created new database tables (`workout_program`, `program_session`, `person_program_assignment`).
</li>
<li>Added a new section under `/programs/` to create, view, and list workout program templates.</li>
<li>Program creation allows defining multiple sessions, each with a name and a list of selected
exercises.</li>
<li>The system automatically finds or creates non-person-specific tags based on the selected
exercises for each session.</li>
<li>Added functionality to delete programs from the list view.</li>
<li>Implemented HTMX for dynamic loading of the program view page from the list page.</li>
<li>Integrated `tail.select` for searchable exercise dropdowns in the program creation form.</li>
</ul>
</ul>
<!-- New Entry for SQL Explorer SVG Plots -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 19, 2025</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Refactored tag management functionality:</li>
<ul class="list-disc pl-5 space-y-1">
<li>Moved tag-related routes (`add_tag`, `delete_tag`, `goto_tag`) from `app.py` to a new blueprint
`routes/tags.py`.</li>
<li>Changed `add_tag` endpoint to use `POST` and `delete_tag` to use `DELETE`.</li>
<li>Updated `add_tag` and `delete_tag` to return the updated `tags.html` partial via HTMX swap.</li>
<li>Wrapped the inclusion of `tags.html` in `dashboard.html` and `person_overview.html` with
`div#container` for correct HTMX targeting.</li>
</ul>
</ul>
<!-- New Entry for SQL Explorer SVG Plots -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 15, 2025</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Replaced Plotly graph generation in SQL Explorer with direct SVG rendering:</li>
<ul class="list-disc pl-5 space-y-1">
<li>Updated `plot_query` and `plot_unsaved_query` endpoints in `routes/sql_explorer.py` to fetch raw
data.</li>
<li>Added `prepare_svg_plot_data` function in `utils.py` to process data and determine plot type
(scatter, line, bar, or table fallback).</li>
<li>Created `templates/partials/sql_explorer/svg_plot.html` template to render SVG plots with axes
and basic tooltips.</li>
<li>Removes the need for Plotly library for SQL Explorer plots, reducing dependencies and
potentially improving load times.</li>
</ul>
</ul>
<!-- New Entry for Dismissible Exercise Graph -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 13, 2025</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Added a dismissible exercise progress graph to the workout page:</li>
<ul class="list-disc pl-5 space-y-1">
<li>Added a graph icon next to each exercise name in the topset list
(`templates/partials/topset.html`).</li>
<li>Clicking the icon loads the exercise progress graph inline using HTMX (`hx-get`, `hx-target`).
</li>
<li>Added a dismiss button (cross icon) to the loaded graph area.</li>
<li>Implemented hyperscript (`_`) logic to show the dismiss button when the graph loads and clear
the graph/hide the button when clicked.</li>
</ul>
</ul>
<!-- New Entry for Data Export -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 12, 2025</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Added functionality to export data from the Settings page:</li>
<ul class="list-disc pl-5 space-y-1">
<li>Export all workout set data as a CSV file.</li>
<li>Export full database schema (CREATE statements) and data (INSERT statements) as an SQL script.
</li>
</ul>
</ul>
<!-- New Entry for SQL Generation -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 5, 2025</h2>

View File

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

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 %}
<div data-te-chip-init data-te-ripple-init
class="[word-wrap: break-word] my-[5px] mr-4 flex h-[32px] cursor-pointer items-center justify-between rounded-[16px] border border-[#9fa6b2] bg-[#eceff1] bg-[transparent] py-0 px-[12px] text-[13px] font-normal normal-case leading-loose text-[#4f4f4f] shadow-none transition-[opacity] duration-300 ease-linear hover:border-[#9fa6b2] hover:!shadow-none dark:text-neutral-200"
class="[word-wrap: break-word] my-1 flex h-8 cursor-pointer items-center justify-between rounded-full border border-gray-300 bg-gray-100 py-0 px-3 text-sm font-normal text-gray-700 shadow-none transition-opacity duration-300 ease-linear hover:border-gray-400 hover:shadow-sm dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200"
data-te-ripple-color="dark">
<span hx-get="{{ url_for('goto_tag') }}" {% if person_id %}
hx-vals='{"filter": "{{ t["tag_filter"] }}", "person_id": "{{ person_id }}"}' {% else %}
hx-vals='{"filter": "{{ t["tag_filter"] }}"}' {% endif%} hx-target="#container" hx-push-url="true">{{
t['tag_name'] }}</span>
{# Tag Name (Clickable to filter) #}
<span class="pr-2" hx-get="{{ url_for('tags.goto_tag') }}"
hx-vals='{"filter": "{{ t.tag_filter }}", "person_id": "{{ person_id | default("", true) }}"}'
hx-target="#container" hx-push-url="true">{{ t.tag_name }}</span>
{# Delete Button #}
<span
class="float-right w-4 cursor-pointer pl-[8px] text-[16px] text-[#afafaf] opacity-[.53] transition-all duration-200 ease-in-out hover:text-[#8b8b8b] dark:text-neutral-400 dark:hover:text-neutral-100"
hx-get="{{ url_for('delete_tag', tag_id=t['tag_id']) }}" {% if person_id %}
hx-vals='{"filter": "{{ t["tag_filter"] }}", "person_id": "{{ person_id }}"}' {% else %}
hx-vals='{"filter": "{{ t["tag_filter"] }}"}' {% endif%} hx-target="#container" hx-push-url="true" _="on htmx:confirm(issueRequest)
halt the event
call Swal.fire({title: 'Confirm', text:'Are you sure you want to delete {{ t['tag_name'] }} tag?'})
if result.isConfirmed issueRequest()">
class="ml-1 cursor-pointer text-gray-400 hover:text-gray-600 dark:text-neutral-400 dark:hover:text-neutral-100"
hx-delete="{{ url_for('tags.delete_tag', tag_id=t.tag_id) }}" hx-target="#tags-container" {# Target the
container to refresh the list #} hx-swap="outerHTML" {# Replace the whole container #}
hx-confirm="Are you sure you want to delete the '{{ t.tag_name }}' tag?"
hx-vals='{"person_id": "{{ person_id | default("", true) }}", "current_filter": "{{ request.query_string | default("", true) }}"}'>
{# Pass context if needed by backend #}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-3 w-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -26,52 +27,44 @@
</div>
{% endfor %}
<div class="flex justify-center space-x-2">
<div>
<button type="button" data-te-ripple-init data-te-ripple-color="light"
class="inline-block rounded-full bg-primary p-2 uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-primary-600 hover:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:bg-primary-600 focus:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)] focus:outline-none focus:ring-0 active:bg-primary-700 active:shadow-[0_8px_9px_-4px_rgba(59,113,202,0.3),0_4px_18px_0_rgba(59,113,202,0.2)]"
id="add-tag">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
{# Add Tag Section - Initially Hidden Button, reveals Form #}
<div id="add-tag-section" class="my-1">
{# Show Add Button #}
<button id="show-add-tag-form-btn"
class="inline-block rounded-full bg-blue-500 p-2 text-xs font-medium uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-blue-600 hover:shadow-lg focus:bg-blue-600 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-700 active:shadow-lg"
_="on click toggle .hidden on #add-tag-form then toggle .hidden on me">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
{# Add Tag Form (Initially Hidden) #}
<form id="add-tag-form" class="hidden flex items-center space-x-1" hx-post="{{ url_for('tags.add_tag') }}"
hx-target="#tags-container" {# Target the container to refresh the list #} hx-swap="outerHTML" {# Replace
the whole container #}
_="on htmx:afterRequest toggle .hidden on #show-add-tag-form-btn then toggle .hidden on me then set me.tag_name.value to ''">
{# Hide form, show button, clear input after submit #}
<input type="hidden" name="person_id" value="{{ person_id | default('', true) }}">
<input type="hidden" name="current_filter" value="{{ request.query_string.decode() | default('', true) }}">
{# Pass
context
if needed #}
<input type="text" name="tag_name" required
class="h-8 rounded border border-gray-300 px-2 text-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="New tag...">
<button type="submit"
class="inline-block rounded bg-green-500 px-3 py-1.5 text-xs font-medium uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-green-600 hover:shadow-lg focus:bg-green-600 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-green-700 active:shadow-lg">
Add
</button>
</div>
<button type="button"
class="inline-block rounded bg-gray-400 px-3 py-1.5 text-xs font-medium uppercase leading-normal text-white shadow-md transition duration-150 ease-in-out hover:bg-gray-500 hover:shadow-lg focus:bg-gray-500 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-gray-600 active:shadow-lg"
_="on click toggle .hidden on #show-add-tag-form-btn then toggle .hidden on the closest <form/>">
Cancel
</button>
</form>
</div>
</div>
<script>
document.querySelector('#add-tag').addEventListener('click', function () {
Swal.fire({
title: 'Create a tag',
input: 'text',
inputAttributes: {
autocapitalize: 'off'
},
showCancelButton: true,
confirmButtonText: 'Add',
showLoaderOnConfirm: true,
preConfirm: (tag) => {
return fetch(`{{ url_for('add_tag') }}?tag=${encodeURIComponent(tag)}&filter=${encodeURIComponent(window.location.search)}{% if person_id %}{{ "&person_id={person_id}".format(person_id=person_id) | safe }} {% endif%}`)
.then(response => {
if (!response.ok) {
throw new Error(response.statusText)
}
return response.text()
})
.catch(error => {
Swal.showValidationMessage(
`Request failed: ${error}`
)
})
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (result.isConfirmed) {
htmx.ajax('GET', `{{ (url_for('person_overview', person_id=person_id) if person_id else url_for('dashboard')) + '?' + request.query_string.decode() }}`, '#container')
}
})
})
</script>
</div>

View File

@@ -1,11 +1,24 @@
<tr id="topset-{{ topset_id }}">
<td class="p-0 sm:p-4 text-sm font-semibold text-gray-900 break-normal">
{% if is_edit|default(false, true) == false %}
<span class="cursor-pointer" hx-get="{{ url_for('goto_tag') }}"
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name
}}</span>
<div class="flex items-center space-x-2">
<span class="cursor-pointer" hx-get="{{ url_for('tags.goto_tag') }}"
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name }}</span>
<button
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
title="Show Progress Graph"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-target="#graph-content-{{ topset_id }}" hx-swap="innerHTML">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
<span class="sr-only">Show Progress Graph</span>
</button>
</div>
{% else %}
<div class="w-full">
<select name="exercise_id"
@@ -90,4 +103,34 @@
{% endif %}
</div>
</td>
</tr>
{# Target row modified for dismissible graph #}
<tr id="graph-target-{{ topset_id }}">
<td colspan="3" class="p-0 relative">
<div id="graph-content-{{ topset_id }}" class="graph-content-container" _="
on htmx:afterSwap
get the next <button.dismiss-button/>
if my.innerHTML is not empty and my.innerHTML is not ' '
remove .hidden from it
else
add .hidden to it
end
end">
<!-- Progress graph will be loaded here -->
</div>
<button
class="absolute top-1 right-1 p-1 bg-white rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 z-10 dismiss-button hidden"
title="Dismiss Graph" _="on click
get #graph-content-{{ topset_id }}
set its innerHTML to ''
add .hidden to me
end">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg>
<span class="sr-only">Dismiss Graph</span>
</button>
</td>
</tr>

View File

@@ -35,7 +35,7 @@
<div class="relative">
<div class="w-full">
<select multiple name="tag_id"
hx-post="{{ url_for('workout.add_tag_to_workout', person_id=person_id, workout_id=workout_id) }}"
hx-post="{{ url_for('tags.add_tag_to_workout', workout_id=workout_id) }}"
hx-target="#tag-wrapper-w-{{ workout_id }}"
class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
_="init js(me)
@@ -65,8 +65,7 @@
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
type="text" name="tag_name">
<button type="submit"
hx-post="{{ url_for('workout.create_new_tag_for_workout', person_id=person_id, workout_id=workout_id) }}"
<button type="submit" hx-post="{{ url_for('tags.create_new_tag_for_workout', workout_id=workout_id) }}"
hx-include="[name='tag_name']" hx-target="#tag-wrapper-w-{{ workout_id }}"
class="p-2.5 ml-2 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"

View File

@@ -1,10 +1,11 @@
{% if tags|length == 0 %}
{% if tags|length > 0 %}
{% for tag in tags %}
{% if tag.is_selected %}
<span
class="text-xs font-semibold inline-block py-1 px-2 uppercase rounded text-pink-600 bg-pink-200 uppercase last:mr-0 mr-1 max-h-fit cursor-pointer"
hx-get="{{ url_for('goto_tag') }}" hx-vals='{"filter": "{{ tag.tag_filter }}", "person_id": "{{ tag.person_id }}"}'
hx-target="#container" hx-push-url="true">
hx-get="{{ url_for('tags.goto_tag') }}"
hx-vals='{"filter": "{{ tag.tag_filter }}", "person_id": "{{ tag.person_id }}"}' hx-target="#container"
hx-push-url="true">
{{ tag.tag_name }}
</span>
{% endif %}

View File

@@ -92,7 +92,9 @@
</div>
</div>
{{ render_partial('partials/tags.html',person_id=person_id, tags=tags) }}
<div id="tags-container">
{{ render_partial('partials/tags.html', person_id=person_id, tags=tags) }}
</div>
<div class="hidden" hx-get="{{ url_for('get_people_graphs') }}"
hx-include="[name='exercise_id'],[name='min_date'],[name='max_date']"

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>
<!-- Data Export Section -->
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Data Export</h3>
</div>
</div>
<div class="flex flex-col space-y-4"> <!-- Added space-y-4 for spacing between buttons -->
<p class="text-sm text-gray-600">Download all workout set data as a CSV file, or the entire database
structure and data as an SQL script.</p>
<a href="{{ url_for('export.export_workouts_csv') }}" class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium
rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v3.586l-1.293-1.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V8z"
clip-rule="evenodd"></path>
</svg>
Export All Workouts (CSV)
</a>
<a href="{{ url_for('export.export_database_sql') }}"
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg> <!-- Using a generic download/database icon -->
Export Database (SQL Script)
</a>
</div>
</div>
</div>
{% endblock %}

230
utils.py
View File

@@ -3,7 +3,9 @@ from datetime import datetime, date, timedelta
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.io as pio
import plotly.io as pio # Keep for now, might remove later if generate_plot is fully replaced
import math
from decimal import Decimal
def convert_str_to_date(date_str, format='%Y-%m-%d'):
try:
@@ -141,4 +143,228 @@ def calculate_estimated_1rm(weight, repetitions):
if repetitions == 0: # Avoid division by zero
return 0
estimated_1rm = round((100 * int(weight)) / (101.3 - 2.67123 * repetitions), 0)
return int(estimated_1rm)
return int(estimated_1rm)
def _is_numeric(val):
"""Check if a value is numeric (int, float, Decimal)."""
return isinstance(val, (int, float, Decimal))
def _is_datetime(val):
"""Check if a value is a date or datetime object."""
return isinstance(val, (date, datetime))
def _get_column_type(results, column_name):
"""Determine the effective type of a column (numeric, datetime, categorical)."""
numeric_count = 0
datetime_count = 0
total_count = 0
for row in results:
val = row.get(column_name)
if val is not None:
total_count += 1
if _is_numeric(val):
numeric_count += 1
elif _is_datetime(val):
datetime_count += 1
if total_count == 0: return 'categorical' # Default if all null or empty
if numeric_count / total_count > 0.8: return 'numeric' # Allow some non-numeric noise
if datetime_count / total_count > 0.8: return 'datetime'
return 'categorical'
def _normalize_value(value, min_val, range_val, target_max):
"""Normalize a value to a target range (e.g., SVG coordinate)."""
if range_val == 0: return target_max / 2 # Avoid division by zero, place in middle
return ((value - min_val) / range_val) * target_max
def prepare_svg_plot_data(results, columns, title):
"""
Prepares data from raw SQL results for SVG plotting.
Determines plot type and scales data.
"""
if not results:
raise ValueError("No data provided for plotting.")
num_columns = len(columns)
plot_type = 'table' # Default if no suitable plot found
plot_data = {}
x_col, y_col = None, None
x_type, y_type = None, None
# --- Determine Plot Type and Columns ---
if num_columns == 1:
x_col = columns[0]
x_type = _get_column_type(results, x_col)
if x_type == 'numeric':
plot_type = 'histogram'
else:
plot_type = 'bar_count' # Bar chart of value counts
elif num_columns >= 2:
# Prioritize common patterns
x_col, y_col = columns[0], columns[1]
x_type = _get_column_type(results, x_col)
y_type = _get_column_type(results, y_col)
if x_type == 'numeric' and y_type == 'numeric':
plot_type = 'scatter'
elif x_type == 'datetime' and y_type == 'numeric':
plot_type = 'line' # Treat datetime as numeric for position
elif x_type == 'categorical' and y_type == 'numeric':
plot_type = 'bar'
elif x_type == 'numeric' and y_type == 'categorical':
# Could do horizontal bar, but let's stick to vertical for now
plot_type = 'bar' # Treat numeric as category label, categorical as value (count?) - less common
# Or maybe swap? Let's assume categorical X, numeric Y is more likely intended
x_col, y_col = columns[1], columns[0] # Try swapping
x_type, y_type = y_type, x_type
if not (x_type == 'categorical' and y_type == 'numeric'):
plot_type = 'table' # Revert if swap didn't help
else: # Other combinations (datetime/cat, cat/cat, etc.) default to table
plot_type = 'table'
# --- Basic SVG Setup ---
vb_width = 500
vb_height = 300
margin = {'top': 20, 'right': 20, 'bottom': 50, 'left': 60} # Increased bottom/left for labels/axes
draw_width = vb_width - margin['left'] - margin['right']
draw_height = vb_height - margin['top'] - margin['bottom']
plot_data = {
'title': title,
'plot_type': plot_type,
'vb_width': vb_width,
'vb_height': vb_height,
'margin': margin,
'draw_width': draw_width,
'draw_height': draw_height,
'x_axis_label': x_col or '',
'y_axis_label': y_col or '',
'plots': [],
'x_ticks': [],
'y_ticks': [],
'original_results': results, # Keep original for table fallback
'original_columns': columns
}
if plot_type == 'table':
return plot_data # No further processing needed for table fallback
# --- Data Extraction and Scaling (Specific to Plot Type) ---
points = []
x_values_raw = []
y_values_raw = []
# Extract relevant data, handling potential type issues
for row in results:
x_val_raw = row.get(x_col)
y_val_raw = row.get(y_col)
# Convert datetimes to numeric representation (e.g., days since min date)
if x_type == 'datetime':
x_values_raw.append(x_val_raw) # Keep original dates for range calculation
elif _is_numeric(x_val_raw):
x_values_raw.append(float(x_val_raw)) # Convert Decimal to float
# Add handling for categorical X if needed (e.g., bar chart)
if y_type == 'numeric':
if _is_numeric(y_val_raw):
y_values_raw.append(float(y_val_raw))
else:
y_values_raw.append(None) # Mark non-numeric Y as None
# Add handling for categorical Y if needed
if not x_values_raw or not y_values_raw:
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
return plot_data
# Calculate ranges (handle datetime separately)
if x_type == 'datetime':
valid_dates = [d for d in x_values_raw if d is not None]
if not valid_dates:
plot_data['plot_type'] = 'table'; return plot_data
min_x_dt, max_x_dt = min(valid_dates), max(valid_dates)
# Convert dates to days since min_date for numerical scaling
total_days = (max_x_dt - min_x_dt).days
x_values_numeric = [(d - min_x_dt).days if d is not None else None for d in x_values_raw]
min_x, max_x = 0, total_days
else: # Numeric or Categorical (treat categorical index as numeric for now)
valid_x = [x for x in x_values_raw if x is not None]
if not valid_x:
plot_data['plot_type'] = 'table'; return plot_data
min_x, max_x = min(valid_x), max(valid_x)
x_values_numeric = x_values_raw # Already numeric (or will be treated as such)
valid_y = [y for y in y_values_raw if y is not None]
if not valid_y:
plot_data['plot_type'] = 'table'; return plot_data
min_y, max_y = min(valid_y), max(valid_y)
range_x = max_x - min_x
range_y = max_y - min_y
# Scale points
for i, row in enumerate(results):
x_num = x_values_numeric[i]
y_num = y_values_raw[i] # Use original list which might have None
if x_num is None or y_num is None: continue # Skip points with missing essential data
# Scale X to drawing width, Y to drawing height (inverted Y for SVG)
scaled_x = margin['left'] + _normalize_value(x_num, min_x, range_x, draw_width)
scaled_y = margin['top'] + draw_height - _normalize_value(y_num, min_y, range_y, draw_height)
points.append({
'x': scaled_x,
'y': scaled_y,
'original': row # Store original row data for tooltips
})
# --- Generate Ticks ---
num_ticks = 5 # Desired number of ticks
# X Ticks
x_ticks = []
if range_x >= 0:
step_x = (max_x - min_x) / (num_ticks -1) if num_ticks > 1 and range_x > 0 else 0
for i in range(num_ticks):
tick_val_raw = min_x + i * step_x
tick_pos = margin['left'] + _normalize_value(tick_val_raw, min_x, range_x, draw_width)
label = ""
if x_type == 'datetime':
tick_date = min_x_dt + timedelta(days=tick_val_raw)
label = tick_date.strftime('%Y-%m-%d') # Format date label
else: # Numeric
label = f"{tick_val_raw:.1f}" if isinstance(tick_val_raw, float) else str(tick_val_raw)
x_ticks.append({'value': tick_val_raw, 'label': label, 'position': tick_pos})
# Y Ticks
y_ticks = []
if range_y >= 0:
step_y = (max_y - min_y) / (num_ticks - 1) if num_ticks > 1 and range_y > 0 else 0
for i in range(num_ticks):
tick_val = min_y + i * step_y
tick_pos = margin['top'] + draw_height - _normalize_value(tick_val, min_y, range_y, draw_height)
label = f"{tick_val:.1f}" if isinstance(tick_val, float) else str(tick_val)
y_ticks.append({'value': tick_val, 'label': label, 'position': tick_pos})
# --- Finalize Plot Data ---
# For now, put all points into one series
plot_data['plots'].append({
'label': f'{y_col} vs {x_col}',
'color': '#388fed', # Default color
'points': points
})
plot_data['x_ticks'] = x_ticks
plot_data['y_ticks'] = y_ticks
# Add specific adjustments for plot types if needed (e.g., bar width)
if plot_type == 'bar':
# Calculate bar width based on number of bars/categories
# This needs more refinement based on how categorical X is handled
plot_data['bar_width'] = draw_width / len(points) * 0.8 if points else 10
return plot_data