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.
This commit is contained in:
2
app.py
2
app.py
@@ -14,6 +14,7 @@ from routes.sql_explorer import sql_explorer_bp # Import the new SQL explorer bl
|
||||
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
|
||||
@@ -48,6 +49,7 @@ app.register_blueprint(sql_explorer_bp) # Register the SQL explorer blueprint (p
|
||||
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):
|
||||
|
||||
271
routes/programs.py
Normal file
271
routes/programs.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, current_app
|
||||
from extensions import db
|
||||
# from flask_login import login_required, current_user # Add if authentication is needed
|
||||
from jinja2_fragments import render_block # Import render_block
|
||||
|
||||
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
|
||||
|
||||
from flask import flash # Import flash for displaying messages
|
||||
|
||||
@programs_bp.route('/create', methods=['GET', 'POST'])
|
||||
# @login_required # Uncomment if login is required
|
||||
def create_program():
|
||||
if request.method == 'POST':
|
||||
program_name = request.form.get('program_name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
sessions_data = []
|
||||
i = 0
|
||||
while True:
|
||||
# Check for the presence of session order to determine if the session exists
|
||||
session_order_key = f'session_order_{i}'
|
||||
if session_order_key not in request.form:
|
||||
break # No more sessions
|
||||
|
||||
session_order = request.form.get(session_order_key)
|
||||
session_name = request.form.get(f'session_name_{i}', '').strip()
|
||||
# Get list of selected exercise IDs for this session
|
||||
exercise_ids_str = request.form.getlist(f'exercises_{i}')
|
||||
|
||||
# Basic validation for session data
|
||||
if not exercise_ids_str or not session_order:
|
||||
flash(f"Error processing session {i+1}: Missing exercises or order.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
try:
|
||||
# Convert exercise IDs to integers and sort them for consistent filter generation
|
||||
exercise_ids = sorted([int(eid) for eid in exercise_ids_str])
|
||||
|
||||
sessions_data.append({
|
||||
'order': int(session_order),
|
||||
'name': session_name if session_name else None, # Store None if empty
|
||||
'exercise_ids': exercise_ids # Store the list of exercise IDs
|
||||
})
|
||||
except ValueError:
|
||||
flash(f"Error processing session {i+1}: Invalid exercise ID or order.", "error")
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
i += 1
|
||||
|
||||
# --- Validation ---
|
||||
if not program_name:
|
||||
flash("Program Name is required.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
if not sessions_data:
|
||||
flash("At least one session must be added.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
# --- Database Insertion ---
|
||||
try:
|
||||
# Insert Program
|
||||
program_result = db.execute(
|
||||
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
|
||||
[program_name, description if description else None],
|
||||
commit=True, one=True
|
||||
)
|
||||
if not program_result or 'program_id' not in program_result:
|
||||
raise Exception("Failed to create workout program entry.")
|
||||
|
||||
new_program_id = program_result['program_id']
|
||||
|
||||
# Insert Sessions (and find/create tags)
|
||||
for session in sessions_data:
|
||||
# 1. Generate the canonical filter string from sorted exercise IDs
|
||||
if not session['exercise_ids']:
|
||||
flash(f"Session {session['order']} must have at least one exercise selected.", "error")
|
||||
# Ideally, rollback program insert or handle differently
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in session['exercise_ids'])
|
||||
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises" # Default tag name
|
||||
|
||||
# 2. Find existing tag with this exact filter (non-person specific)
|
||||
existing_tag = db.execute(
|
||||
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
||||
[tag_filter], one=True
|
||||
)
|
||||
|
||||
session_tag_id = None
|
||||
if existing_tag:
|
||||
session_tag_id = existing_tag['tag_id']
|
||||
# Optional: Update tag name if session name provided and different?
|
||||
# db.execute("UPDATE tag SET name = %s WHERE tag_id = %s", [tag_name, session_tag_id], commit=True)
|
||||
else:
|
||||
# 3. Create new tag if not found
|
||||
# Ensure tag name uniqueness if desired (e.g., append number if name exists)
|
||||
# For simplicity, allow duplicate names for now, rely on filter for uniqueness
|
||||
new_tag_result = db.execute(
|
||||
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
||||
[tag_name, tag_filter], commit=True, one=True
|
||||
)
|
||||
if not new_tag_result or 'tag_id' not in new_tag_result:
|
||||
raise Exception(f"Failed to create tag for session {session['order']}.")
|
||||
session_tag_id = new_tag_result['tag_id']
|
||||
|
||||
# 4. Insert program_session using the found/created tag_id
|
||||
db.execute(
|
||||
"""INSERT INTO program_session (program_id, session_order, session_name, tag_id)
|
||||
VALUES (%s, %s, %s, %s)""",
|
||||
[new_program_id, session['order'], session['name'], session_tag_id],
|
||||
commit=True # Commit each session insert
|
||||
)
|
||||
|
||||
flash(f"Workout Program '{program_name}' created successfully!", "success")
|
||||
# TODO: Redirect to a program view page once it exists
|
||||
# return redirect(url_for('programs.view_program', program_id=new_program_id))
|
||||
return redirect(url_for('programs.list_programs')) # Redirect to a list page for now
|
||||
|
||||
except Exception as e:
|
||||
# Log the error e
|
||||
print(f"Error creating program: {e}") # Basic logging
|
||||
flash(f"Database error creating program: {e}", "error")
|
||||
# Rollback might be needed if using transactions across inserts
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
else: # GET Request
|
||||
# Fetch all available exercises to populate multi-selects
|
||||
exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
|
||||
if exercises is None:
|
||||
exercises = [] # Ensure exercises is an iterable
|
||||
|
||||
# Pass exercises to the template context
|
||||
return render_template('program_create.html', exercises=exercises, render_block=render_block) # Pass exercises instead of tags
|
||||
|
||||
|
||||
from flask_htmx import HTMX # Import HTMX
|
||||
|
||||
htmx = HTMX() # Initialize HTMX if not already done globally
|
||||
|
||||
# Placeholder for program list route (used in POST redirect)
|
||||
@programs_bp.route('/', methods=['GET'])
|
||||
# @login_required
|
||||
def list_programs():
|
||||
# Fetch and display list of programs
|
||||
programs = db.execute("SELECT program_id, name, description FROM workout_program ORDER BY created_at DESC")
|
||||
if programs is None:
|
||||
programs = []
|
||||
|
||||
# Check if it's an HTMX request
|
||||
if htmx:
|
||||
# Render only the content block for HTMX requests
|
||||
return render_block(current_app.jinja_env, 'program_list.html', 'content', programs=programs)
|
||||
else:
|
||||
# Render the full page for regular requests
|
||||
return render_template('program_list.html', programs=programs)
|
||||
|
||||
|
||||
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
|
||||
# @login_required # Add authentication if needed
|
||||
def delete_program(program_id):
|
||||
"""Deletes a workout program and its associated sessions/assignments."""
|
||||
try:
|
||||
# The ON DELETE CASCADE constraint on program_session and person_program_assignment
|
||||
# should handle deleting related rows automatically when the program is deleted.
|
||||
result = db.execute(
|
||||
"DELETE FROM workout_program WHERE program_id = %s RETURNING program_id",
|
||||
[program_id],
|
||||
commit=True, one=True
|
||||
)
|
||||
if result and result.get('program_id') == program_id:
|
||||
# Return empty response for HTMX, maybe trigger list refresh
|
||||
# flash(f"Program ID {program_id} deleted successfully.", "success") # Flash might not show on empty response
|
||||
response = "" # Empty response indicates success to HTMX
|
||||
headers = {"HX-Trigger": "programDeleted"} # Trigger event for potential list refresh
|
||||
return response, 200, headers
|
||||
else:
|
||||
# Program not found or delete failed silently
|
||||
flash(f"Could not find or delete program ID {program_id}.", "error")
|
||||
# Returning an error status might be better for HTMX error handling
|
||||
return "Error: Program not found or deletion failed", 404
|
||||
|
||||
except Exception as e:
|
||||
# Log the error e
|
||||
print(f"Error deleting program {program_id}: {e}")
|
||||
flash(f"Database error deleting program: {e}", "error")
|
||||
# Return an error status for HTMX
|
||||
return "Server error during deletion", 500
|
||||
|
||||
|
||||
# TODO: Add routes for viewing, editing, and assigning programs
|
||||
from urllib.parse import parse_qs # Needed to parse tag filters
|
||||
|
||||
@programs_bp.route('/<int:program_id>', methods=['GET'])
|
||||
# @login_required
|
||||
def view_program(program_id):
|
||||
"""Displays the details of a specific workout program."""
|
||||
# Fetch program details
|
||||
program = db.execute(
|
||||
"SELECT program_id, name, description, created_at FROM workout_program WHERE program_id = %s",
|
||||
[program_id], one=True
|
||||
)
|
||||
if not program:
|
||||
flash(f"Workout Program with ID {program_id} not found.", "error")
|
||||
return redirect(url_for('programs.list_programs'))
|
||||
|
||||
# Fetch sessions and their associated tags
|
||||
sessions = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
ps.session_id, ps.session_order, ps.session_name,
|
||||
t.tag_id, t.name as tag_name, t.filter as tag_filter
|
||||
FROM program_session ps
|
||||
JOIN tag t ON ps.tag_id = t.tag_id
|
||||
WHERE ps.program_id = %s
|
||||
ORDER BY ps.session_order ASC
|
||||
""",
|
||||
[program_id]
|
||||
)
|
||||
|
||||
# Process sessions to extract exercise IDs and fetch exercise names
|
||||
sessions_with_exercises = []
|
||||
if sessions:
|
||||
for session in sessions:
|
||||
exercise_ids = []
|
||||
if session.get('tag_filter'):
|
||||
# Parse the filter string (e.g., "?exercise_id=5&exercise_id=1009")
|
||||
parsed_filter = parse_qs(session['tag_filter'].lstrip('?'))
|
||||
exercise_ids_str = parsed_filter.get('exercise_id', [])
|
||||
try:
|
||||
# Ensure IDs are unique and sorted if needed, though order might matter from filter
|
||||
exercise_ids = sorted(list(set(int(eid) for eid in exercise_ids_str)))
|
||||
except ValueError:
|
||||
print(f"Warning: Could not parse exercise IDs from filter for tag {session['tag_id']}: {session['tag_filter']}")
|
||||
exercise_ids = [] # Handle parsing error gracefully
|
||||
|
||||
exercises = []
|
||||
if exercise_ids:
|
||||
# Fetch exercise details for the extracted IDs
|
||||
# Using tuple() for IN clause compatibility
|
||||
# Ensure tuple has at least one element for SQL IN clause
|
||||
if len(exercise_ids) == 1:
|
||||
exercises_tuple = (exercise_ids[0],) # Comma makes it a tuple
|
||||
else:
|
||||
exercises_tuple = tuple(exercise_ids)
|
||||
|
||||
exercises = db.execute(
|
||||
"SELECT exercise_id, name FROM exercise WHERE exercise_id IN %s ORDER BY name",
|
||||
[exercises_tuple]
|
||||
)
|
||||
if exercises is None: exercises = [] # Ensure it's iterable
|
||||
|
||||
sessions_with_exercises.append({
|
||||
**session, # Include all original session/tag data
|
||||
'exercises': exercises
|
||||
})
|
||||
|
||||
# Prepare context for the template
|
||||
context = {
|
||||
'program': program,
|
||||
'sessions': sessions_with_exercises
|
||||
}
|
||||
|
||||
# Check for HTMX request (optional, for potential future use)
|
||||
if htmx:
|
||||
# Assuming you have a block named 'content' in program_view.html
|
||||
return render_block(current_app.jinja_env, 'program_view.html', 'content', **context)
|
||||
else:
|
||||
return render_template('program_view.html', **context)
|
||||
|
||||
# TODO: Add routes for editing and assigning programs
|
||||
@@ -155,6 +155,25 @@
|
||||
</div>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<a hx-get="{{ url_for('programs.list_programs') }}" hx-push-url="true"
|
||||
hx-target="#container"
|
||||
class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 group transition duration-75 flex items-center p-2 cursor-pointer {{ is_selected_page(url_for('sql_explorer.sql_explorer')) }} page-link"
|
||||
_="on click add .hidden to #sidebar then remove .ml-64 from #main
|
||||
on htmx:afterRequest go to the top of the body">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
||||
viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M6 5v14h3v-6h6v6h3V5h-3v6H9V5zM3 15a1 1 0 0 0 1 1h1V8H4a1 1 0 0 0-1 1v2H2v2h1v2zm18-6a1 1 0 0 0-1-1h-1v8h1a1 1 0 0 0 1-1v-2h1v-2h-1V9z">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="ml-3">Programs</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<a hx-get="{{ url_for('sql_explorer.sql_explorer') }}" hx-push-url="true"
|
||||
hx-target="#container"
|
||||
|
||||
@@ -10,6 +10,25 @@
|
||||
<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>
|
||||
|
||||
318
templates/program_create.html
Normal file
318
templates/program_create.html
Normal file
@@ -0,0 +1,318 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Workout Program{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-3xl"> {# Constrain width #}
|
||||
<h1 class="text-3xl font-bold mb-8 text-center text-gray-800">Create New Workout Program</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('programs.create_program') }}" id="create-program-form"
|
||||
class="bg-white shadow-md rounded-lg px-8 pt-6 pb-8 mb-4">
|
||||
{# Program Details Section #}
|
||||
<div class="mb-6 border-b border-gray-200 pb-4">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-700">Program Details</h2>
|
||||
<div class="mb-4">
|
||||
<label for="program_name" class="block text-gray-700 text-sm font-bold mb-2">Program Name:</label>
|
||||
<input type="text" id="program_name" name="program_name" required
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-gray-700 text-sm font-bold mb-2">Description
|
||||
(Optional):</label>
|
||||
<textarea id="description" name="description" rows="3"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sessions Section #}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-700">Sessions</h2>
|
||||
<div id="sessions-container" class="space-y-6 mb-4"> {# Increased spacing #}
|
||||
<!-- Session rows will be added here by JavaScript -->
|
||||
</div>
|
||||
<button type="button" id="add-session-btn"
|
||||
class="mt-2 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Add Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{# Form Actions #}
|
||||
<div class="flex items-center justify-end pt-4 border-t border-gray-200">
|
||||
<button type="submit"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
|
||||
Create Program
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# HTML Template for a single session row #}
|
||||
<template id="session-row-template">
|
||||
<div class="session-row bg-gray-50 border border-gray-300 rounded-lg shadow-sm overflow-hidden"
|
||||
data-index="SESSION_INDEX_PLACEHOLDER">
|
||||
{# Session Header #}
|
||||
<div class="px-4 py-3 bg-gray-100 border-b border-gray-300 flex justify-between items-center">
|
||||
<h3 class="session-day-number text-lg font-semibold text-gray-700">Day SESSION_DAY_NUMBER_PLACEHOLDER
|
||||
</h3>
|
||||
<input type="hidden" name="session_order_SESSION_INDEX_PLACEHOLDER"
|
||||
value="SESSION_DAY_NUMBER_PLACEHOLDER">
|
||||
<button type="button" class="remove-session-btn text-red-500 hover:text-red-700" title="Remove Session">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Session Body #}
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<label for="session_name_SESSION_INDEX_PLACEHOLDER"
|
||||
class="block text-sm font-medium text-gray-700 mb-1">Session Name (Optional):</label>
|
||||
<input type="text" id="session_name_SESSION_INDEX_PLACEHOLDER"
|
||||
name="session_name_SESSION_INDEX_PLACEHOLDER" value=""
|
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||
</div>
|
||||
{# Container for individual exercise selects #}
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Exercises:</label>
|
||||
<div class="session-exercises-container space-y-2 border border-gray-200 p-3 rounded-md bg-white">
|
||||
{# Exercise rows will be added here by JS #}
|
||||
</div>
|
||||
<button type="button"
|
||||
class="add-exercise-btn mt-1 inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Add Exercise to Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Nested Template for a single exercise row within a session #}
|
||||
<template id="exercise-row-template">
|
||||
<div class="exercise-row flex items-center space-x-2">
|
||||
{# Wrapper div for tail.select - Added position: relative #}
|
||||
<div class="flex-grow relative">
|
||||
{# Note: tail.select might hide the original select, apply styling to its container if needed #}
|
||||
<select name="exercises_SESSION_INDEX_PLACEHOLDER" required class="exercise-select-original w-full"> {#
|
||||
Keep original select for form submission, tail.select will enhance it #}
|
||||
<option value="">Select Exercise...</option>
|
||||
{# Render options directly here using the exercises passed to the main template #}
|
||||
{% for exercise in exercises %}
|
||||
<option value="{{ exercise.exercise_id }}">{{ exercise.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="remove-exercise-btn text-red-500 hover:text-red-700 flex-shrink-0"
|
||||
title="Remove Exercise">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// No longer need to pass exercises to JS for populating options
|
||||
// const availableExercises = {{ exercises | tojson | safe }}; // Removed
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const sessionsContainer = document.getElementById('sessions-container');
|
||||
const addSessionBtn = document.getElementById('add-session-btn');
|
||||
const sessionTemplate = document.getElementById('session-row-template');
|
||||
const exerciseTemplate = document.getElementById('exercise-row-template');
|
||||
let sessionCounter = sessionsContainer.querySelectorAll('.session-row').length;
|
||||
|
||||
// --- Function to add a new session row ---
|
||||
function addSessionRow() {
|
||||
const newRowFragment = sessionTemplate.content.cloneNode(true);
|
||||
const newRow = newRowFragment.querySelector('.session-row');
|
||||
const currentSessionIndex = sessionCounter;
|
||||
|
||||
if (!newRow) {
|
||||
console.error("Failed to clone session row template.");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Update placeholders and attributes for Session ---
|
||||
newRow.dataset.index = currentSessionIndex;
|
||||
|
||||
const dayNumberSpan = newRow.querySelector('.session-day-number');
|
||||
if (dayNumberSpan) dayNumberSpan.textContent = `Day ${currentSessionIndex + 1}`;
|
||||
|
||||
const orderInput = newRow.querySelector('input[type="hidden"]');
|
||||
if (orderInput) {
|
||||
orderInput.name = `session_order_${currentSessionIndex}`;
|
||||
orderInput.value = currentSessionIndex + 1;
|
||||
}
|
||||
|
||||
const nameLabel = newRow.querySelector('label[for^="session_name_"]');
|
||||
const nameInput = newRow.querySelector('input[id^="session_name_"]');
|
||||
if (nameLabel) nameLabel.htmlFor = `session_name_${currentSessionIndex}`;
|
||||
if (nameInput) {
|
||||
nameInput.id = `session_name_${currentSessionIndex}`;
|
||||
nameInput.name = `session_name_${currentSessionIndex}`;
|
||||
}
|
||||
// --- End Session Placeholder Updates ---
|
||||
|
||||
// Attach listener for the "Add Exercise" button within this new session
|
||||
const addExerciseBtn = newRow.querySelector('.add-exercise-btn');
|
||||
const exercisesContainer = newRow.querySelector('.session-exercises-container');
|
||||
if (addExerciseBtn && exercisesContainer) {
|
||||
addExerciseBtn.dataset.sessionIndex = currentSessionIndex; // Store index
|
||||
addExerciseBtn.addEventListener('click', handleAddExerciseClick);
|
||||
// Add one exercise select automatically when session is added
|
||||
addExerciseSelect(exercisesContainer, currentSessionIndex);
|
||||
}
|
||||
|
||||
sessionsContainer.appendChild(newRowFragment);
|
||||
attachRemoveListener(newRow.querySelector('.remove-session-btn')); // Attach session remove listener
|
||||
sessionCounter++;
|
||||
}
|
||||
|
||||
// --- Function to add an exercise select row to a specific session ---
|
||||
function addExerciseSelect(container, sessionIndex) {
|
||||
const newExFragment = exerciseTemplate.content.cloneNode(true);
|
||||
const originalSelect = newExFragment.querySelector('.exercise-select-original');
|
||||
const removeBtn = newExFragment.querySelector('.remove-exercise-btn');
|
||||
|
||||
if (!originalSelect || !removeBtn) {
|
||||
console.error("Failed to find original select or remove button in exercise template clone.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the name attribute correctly for getlist
|
||||
originalSelect.name = `exercises_${sessionIndex}`;
|
||||
|
||||
container.appendChild(newExFragment);
|
||||
|
||||
// Find the newly added select element *after* appending
|
||||
const newSelectElement = container.querySelector('.exercise-row:last-child .exercise-select-original');
|
||||
|
||||
// Initialize tail.select on the new element
|
||||
if (newSelectElement && typeof tail !== 'undefined' && tail.select) {
|
||||
tail.select(newSelectElement, {
|
||||
search: true,
|
||||
placeholder: 'Select Exercise...',
|
||||
// classNames: "w-full" // Add tailwind classes if needed for the generated dropdown
|
||||
});
|
||||
} else {
|
||||
console.warn("tail.select library not found or new select element not found. Using standard select.");
|
||||
}
|
||||
|
||||
// Attach remove listener to the new exercise row's button
|
||||
attachExerciseRemoveListener(removeBtn);
|
||||
}
|
||||
|
||||
// --- Event handler for Add Exercise buttons ---
|
||||
function handleAddExerciseClick(event) {
|
||||
const btn = event.currentTarget;
|
||||
const sessionIndex = parseInt(btn.dataset.sessionIndex, 10);
|
||||
const exercisesContainer = btn.closest('.session-row').querySelector('.session-exercises-container');
|
||||
if (!isNaN(sessionIndex) && exercisesContainer) {
|
||||
addExerciseSelect(exercisesContainer, sessionIndex);
|
||||
} else {
|
||||
console.error("Could not find session index or container for Add Exercise button.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Function to attach remove listener for Session rows ---
|
||||
function attachRemoveListener(button) {
|
||||
button.addEventListener('click', function () {
|
||||
this.closest('.session-row').remove();
|
||||
updateSessionNumbers(); // Renumber sessions after removal
|
||||
});
|
||||
}
|
||||
|
||||
// --- Function to attach remove listener for Exercise rows ---
|
||||
function attachExerciseRemoveListener(button) {
|
||||
if (button) {
|
||||
button.addEventListener('click', function () {
|
||||
this.closest('.exercise-row').remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Function to renumber sessions ---
|
||||
function updateSessionNumbers() {
|
||||
const rows = sessionsContainer.querySelectorAll('.session-row');
|
||||
sessionCounter = 0; // Reset counter before renumbering
|
||||
rows.forEach((row, index) => {
|
||||
const newIndex = index;
|
||||
sessionCounter++; // Increment counter for the next row index
|
||||
|
||||
// Update visible day number
|
||||
const daySpan = row.querySelector('.session-day-number');
|
||||
if (daySpan) daySpan.textContent = `Day ${newIndex + 1}`;
|
||||
|
||||
// Update hidden order input value and name
|
||||
const orderInput = row.querySelector('input[type="hidden"]');
|
||||
if (orderInput) {
|
||||
orderInput.name = `session_order_${newIndex}`;
|
||||
orderInput.value = newIndex + 1;
|
||||
}
|
||||
|
||||
// Update IDs and names for session name input/label
|
||||
const nameLabel = row.querySelector('label[for^="session_name_"]');
|
||||
const nameInput = row.querySelector('input[id^="session_name_"]');
|
||||
|
||||
if (nameLabel) nameLabel.htmlFor = `session_name_${newIndex}`;
|
||||
if (nameInput) {
|
||||
nameInput.id = `session_name_${newIndex}`;
|
||||
nameInput.name = `session_name_${newIndex}`;
|
||||
}
|
||||
|
||||
// Update names for the exercise selects within this session
|
||||
const exerciseSelects = row.querySelectorAll('.exercise-select-original'); // Target original selects
|
||||
exerciseSelects.forEach(select => {
|
||||
select.name = `exercises_${newIndex}`;
|
||||
});
|
||||
|
||||
// Update listener for the "Add Exercise" button
|
||||
const addExerciseBtn = row.querySelector('.add-exercise-btn');
|
||||
if (addExerciseBtn) {
|
||||
addExerciseBtn.dataset.sessionIndex = newIndex; // Update index used by listener
|
||||
// No need to re-attach listener if it uses the dataset property correctly
|
||||
}
|
||||
|
||||
// Update data-index attribute
|
||||
row.dataset.index = newIndex;
|
||||
});
|
||||
sessionCounter = rows.length;
|
||||
}
|
||||
|
||||
|
||||
// --- Event Listeners ---
|
||||
addSessionBtn.addEventListener('click', addSessionRow);
|
||||
|
||||
// Attach listeners to initially loaded elements (if any)
|
||||
sessionsContainer.querySelectorAll('.session-row .remove-session-btn').forEach(attachRemoveListener);
|
||||
sessionsContainer.querySelectorAll('.exercise-row .remove-exercise-btn').forEach(attachExerciseRemoveListener);
|
||||
sessionsContainer.querySelectorAll('.session-row .add-exercise-btn').forEach(btn => {
|
||||
const sessionIndex = parseInt(btn.closest('.session-row').dataset.index, 10);
|
||||
if (!isNaN(sessionIndex)) {
|
||||
btn.dataset.sessionIndex = sessionIndex;
|
||||
btn.addEventListener('click', handleAddExerciseClick);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add one session row automatically if none exist initially
|
||||
if (sessionsContainer.children.length === 0) {
|
||||
addSessionRow();
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
85
templates/program_list.html
Normal file
85
templates/program_list.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Workout Programs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Workout Programs</h1>
|
||||
<a href="{{ url_for('programs.create_program') }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Create New Program
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for category, message in messages %}
|
||||
<div class="p-4 rounded-md {{ 'bg-green-100 border-green-400 text-green-700' if category == 'success' else 'bg-red-100 border-red-400 text-red-700' }}"
|
||||
role="alert">
|
||||
<p class="font-bold">{{ category.title() }}</p>
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
{% if programs %}
|
||||
{% for program in programs %}
|
||||
<li id="program-{{ program.program_id }}">
|
||||
{# Use HTMX for dynamic loading #}
|
||||
<a href="{{ url_for('programs.view_program', program_id=program.program_id) }}"
|
||||
class="block hover:bg-gray-50"
|
||||
hx-get="{{ url_for('programs.view_program', program_id=program.program_id) }}"
|
||||
hx-target="#container" hx-push-url="true" hx-swap="innerHTML">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-indigo-600 truncate">{{ program.name }}</p>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2"> {# Added space-x-2 #}
|
||||
{# TODO: Add View/Edit/Assign buttons later #}
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 items-center">
|
||||
{# Added items-center #}
|
||||
ID: {{ program.program_id }}
|
||||
</span>
|
||||
{# Delete Button #}
|
||||
<button type="button" class="text-red-600 hover:text-red-800 focus:outline-none"
|
||||
hx-delete="{{ url_for('programs.delete_program', program_id=program.program_id) }}"
|
||||
hx-target="closest li" hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete the program '{{ program.name }}'? This cannot be undone.">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-gray-500">
|
||||
{{ program.description | default('No description provided.') }}
|
||||
</p>
|
||||
</div>
|
||||
{# <div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
||||
Created: {{ program.created_at | strftime('%Y-%m-%d') }}
|
||||
</div> #}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="px-4 py-4 sm:px-6">
|
||||
<p class="text-sm text-gray-500">No workout programs found. Create one!</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
82
templates/program_view.html
Normal file
82
templates/program_view.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ program.name }} - Program Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
|
||||
{# Back Link #}
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('programs.list_programs') }}" hx-get="{{ url_for('programs.list_programs') }}"
|
||||
hx-target="#container" hx-push-url="true" hx-swap="innerHTML"
|
||||
class="text-indigo-600 hover:text-indigo-800 text-sm">
|
||||
← Back to Programs List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Program Header #}
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h1 class="text-2xl leading-6 font-bold text-gray-900">
|
||||
{{ program.name }}
|
||||
</h1>
|
||||
{% if program.description %}
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
{{ program.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{# Add Edit/Assign buttons here later #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sessions Section #}
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-700">Sessions</h2>
|
||||
<div class="space-y-6">
|
||||
{% if sessions %}
|
||||
{% for session in sessions %}
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-4 sm:px-6 bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Day {{ session.session_order }}{% if session.session_name %}: {{ session.session_name }}{% endif %}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Tag: {{ session.tag_name }} (ID: {{ session.tag_id }})</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">
|
||||
Exercises
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{% if session.exercises %}
|
||||
<ul role="list" class="border border-gray-200 rounded-md divide-y divide-gray-200">
|
||||
{% for exercise in session.exercises %}
|
||||
<li class="pl-3 pr-4 py-3 flex items-center justify-between text-sm">
|
||||
<div class="w-0 flex-1 flex items-center">
|
||||
<!-- Heroicon name: solid/paper-clip -->
|
||||
{# Could add an icon here #}
|
||||
<span class="ml-2 flex-1 w-0 truncate">
|
||||
{{ exercise.name }} (ID: {{ exercise.exercise_id }})
|
||||
</span>
|
||||
</div>
|
||||
{# Add links/actions per exercise later if needed #}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-gray-500 italic">No exercises found for this session's tag filter.</p>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{# Add more session details here if needed #}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-gray-500 italic">This program currently has no sessions defined.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user