Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Stockings
437271bc8c Fix for mobile monthly calendar view so clicking redirects to workout view 2026-02-03 15:21:36 +11:00
Peter Stockings
ac093ec2e0 Update programs functionality 2026-02-03 15:10:59 +11:00
Peter Stockings
b26ae1e319 Adjust monthly calendar view sets font size 2026-02-02 22:47:25 +11:00
8 changed files with 855 additions and 215 deletions

View File

@@ -1,11 +1,14 @@
from flask import Blueprint, render_template, request, redirect, url_for, current_app
import os
import json
from flask import Blueprint, render_template, request, redirect, url_for, current_app, flash, jsonify
from extensions import db
from flask_login import login_required, current_user
from jinja2_fragments import render_block # Import render_block
from jinja2_fragments import render_block
from urllib.parse import parse_qs, urlencode
from flask_htmx import HTMX
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
from flask import flash # Import flash for displaying messages
htmx = HTMX()
@programs_bp.route('/create', methods=['GET', 'POST'])
@login_required
@@ -16,256 +19,380 @@ def create_program():
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
break
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}')
sets_list = request.form.getlist(f'sets_{i}')
reps_list = request.form.getlist(f'reps_{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
flash(f"Error processing session {i+1}: Missing exercises.", "error")
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])
exercise_data = []
for idx, eid in enumerate(exercise_ids_str):
exercise_data.append({
'id': int(eid),
'sets': int(sets_list[idx]) if idx < len(sets_list) and sets_list[idx] else None,
'rep_range': reps_list[idx] if idx < len(reps_list) else None,
'order': idx + 1
})
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
'name': session_name if session_name else None,
'exercises': exercise_data
})
except ValueError:
flash(f"Error processing session {i+1}: Invalid exercise ID or order.", "error")
flash(f"Error processing session {i+1}: Invalid data.", "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'))
exercise_ids = sorted([ex['id'] for ex in session['exercises']])
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises"
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)""",
session_record = db.execute(
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
"VALUES (%s, %s, %s, %s) RETURNING session_id",
[new_program_id, session['order'], session['name'], session_tag_id],
commit=True # Commit each session insert
commit=True, one=True
)
session_id = session_record['session_id']
for ex in session['exercises']:
db.execute(
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
"VALUES (%s, %s, %s, %s, %s)",
[session_id, ex['id'], ex['sets'], ex['rep_range'], ex['order']],
commit=True
)
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
return redirect(url_for('programs.view_program', program_id=new_program_id))
except Exception as e:
# Log the error e
print(f"Error creating program: {e}") # Basic logging
print(f"Error creating program: {e}")
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
else:
exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
if exercises is None:
exercises = [] # Ensure exercises is an iterable
return render_template('program_create.html', exercises=exercises if exercises else [])
# 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
@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 = []
# Enrich programs with sessions and exercises for preview
for program in programs:
sessions = db.execute(
"SELECT session_id, session_order, session_name FROM program_session WHERE program_id = %s ORDER BY session_order",
[program['program_id']]
)
for session in sessions:
exercises = db.execute(
"""SELECT e.name
FROM program_session_exercise pse
JOIN exercise e ON pse.exercise_id = e.exercise_id
WHERE pse.session_id = %s
ORDER BY pse.exercise_order""",
[session['session_id']]
)
session['exercises'] = exercises
program['sessions'] = sessions
# Check if it's an HTMX request
if htmx:
# Render only the content block for HTMX requests
htmx_req = request.headers.get('HX-Request')
if htmx_req:
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)
return render_template('program_list.html', programs=programs)
@programs_bp.route('/import', methods=['GET', 'POST'])
@login_required
def import_program():
htmx_req = request.headers.get('HX-Request')
if request.method == 'POST':
if 'file' not in request.files:
flash("No file part", "error")
return redirect(request.url)
file = request.files['file']
if file.filename == '':
flash("No selected file", "error")
return redirect(request.url)
try:
data = json.load(file)
program_name = data.get('program_name', 'Imported Program')
description = data.get('description', '')
program_result = db.execute(
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
[program_name, description],
commit=True, one=True
)
new_program_id = program_result['program_id']
for session_data in data.get('sessions', []):
order = session_data.get('order')
name = session_data.get('name')
exercises_list = session_data.get('exercises', [])
exercise_ids = sorted([int(ex['id']) for ex in exercises_list])
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
tag_name = name if name else f"Session {order} Exercises"
existing_tag = db.execute(
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
[tag_filter], one=True
)
if existing_tag:
tag_id = existing_tag['tag_id']
else:
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
)
tag_id = new_tag_result['tag_id']
session_result = db.execute(
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
"VALUES (%s, %s, %s, %s) RETURNING session_id",
[new_program_id, order, name, tag_id],
commit=True, one=True
)
session_id = session_result['session_id']
for ex in exercises_list:
db.execute(
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
"VALUES (%s, %s, %s, %s, %s)",
[session_id, ex['id'], ex.get('sets'), ex.get('rep_range'), ex.get('order')],
commit=True
)
flash(f"Program '{program_name}' imported successfully!", "success")
return redirect(url_for('programs.view_program', program_id=new_program_id))
except Exception as e:
flash(f"Error importing program: {e}", "error")
return redirect(url_for('programs.list_programs'))
if htmx_req:
return render_block(current_app.jinja_env, 'program_import.html', 'content')
return render_template('program_import.html')
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
@login_required
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
db.execute("DELETE FROM workout_program WHERE program_id = %s", [program_id], commit=True)
return "", 200
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
return str(e), 500
@programs_bp.route('/<int:program_id>', methods=['GET'])
# @login_required
@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
)
program = db.execute("SELECT * 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")
flash("Program 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]
)
sessions = db.execute("SELECT * FROM program_session WHERE program_id = %s ORDER BY session_order", [program_id])
for session in sessions:
exercises = db.execute(
"""SELECT e.exercise_id, e.name, pse.sets, pse.rep_range, pse.exercise_order
FROM program_session_exercise pse
JOIN exercise e ON pse.exercise_id = e.exercise_id
WHERE pse.session_id = %s
ORDER BY pse.exercise_order""",
[session['session_id']]
)
if not exercises:
tag = db.execute("SELECT filter FROM tag WHERE tag_id = %s", [session['tag_id']], one=True)
if tag and tag['filter']:
from urllib.parse import parse_qs
qs = parse_qs(tag['filter'].lstrip('?'))
exercise_ids = qs.get('exercise_id', [])
if exercise_ids:
exercises = db.execute(
f"SELECT exercise_id, name FROM exercise WHERE exercise_id IN ({','.join(['%s']*len(exercise_ids))})",
exercise_ids
)
session['exercises'] = exercises
# 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
htmx_req = request.headers.get('HX-Request')
if htmx_req:
return render_block(current_app.jinja_env, 'program_view.html', 'content', program=program, sessions=sessions)
return render_template('program_view.html', program=program, sessions=sessions)
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)
@programs_bp.route('/<int:program_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_program(program_id):
program = db.execute("SELECT * FROM workout_program WHERE program_id = %s", [program_id], one=True)
if not program:
flash("Program not found.", "error")
return redirect(url_for('programs.list_programs'))
exercises = db.execute(
"SELECT exercise_id, name FROM exercise WHERE exercise_id IN %s ORDER BY name",
[exercises_tuple]
if request.method == 'POST':
program_name = request.form.get('program_name', '').strip()
description = request.form.get('description', '').strip()
sessions_data = []
i = 0
while True:
session_order_key = f'session_order_{i}'
if session_order_key not in request.form:
break
session_order = request.form.get(session_order_key)
session_name = request.form.get(f'session_name_{i}', '').strip()
exercise_ids_str = request.form.getlist(f'exercises_{i}')
sets_list = request.form.getlist(f'sets_{i}')
reps_list = request.form.getlist(f'reps_{i}')
if not exercise_ids_str or not session_order:
flash(f"Error processing session {i+1}: Missing exercises.", "error")
return redirect(url_for('programs.edit_program', program_id=program_id))
try:
exercise_data = []
for idx, eid in enumerate(exercise_ids_str):
exercise_data.append({
'id': int(eid),
'sets': int(sets_list[idx]) if idx < len(sets_list) and sets_list[idx] else None,
'rep_range': reps_list[idx] if idx < len(reps_list) else None,
'order': idx + 1
})
sessions_data.append({
'order': int(session_order),
'name': session_name if session_name else None,
'exercises': exercise_data
})
except ValueError:
flash(f"Error processing session {i+1}: Invalid data.", "error")
return redirect(url_for('programs.edit_program', program_id=program_id))
i += 1
if not program_name:
flash("Program Name is required.", "error")
return redirect(url_for('programs.edit_program', program_id=program_id))
try:
# Update Program
db.execute(
"UPDATE workout_program SET name = %s, description = %s WHERE program_id = %s",
[program_name, description if description else None, program_id],
commit=True
)
# Delete existing sessions (metadata will be deleted via CASCADE)
db.execute("DELETE FROM program_session WHERE program_id = %s", [program_id], commit=True)
# Re-insert Sessions
for session in sessions_data:
exercise_ids = sorted([ex['id'] for ex in session['exercises']])
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises"
existing_tag = db.execute(
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
[tag_filter], one=True
)
if exercises is None: exercises = [] # Ensure it's iterable
sessions_with_exercises.append({
**session, # Include all original session/tag data
'exercises': exercises
})
if existing_tag:
session_tag_id = existing_tag['tag_id']
else:
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
)
session_tag_id = new_tag_result['tag_id']
# Prepare context for the template
context = {
'program': program,
'sessions': sessions_with_exercises
}
session_record = db.execute(
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
"VALUES (%s, %s, %s, %s) RETURNING session_id",
[program_id, session['order'], session['name'], session_tag_id],
commit=True, one=True
)
session_id = session_record['session_id']
# 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)
for ex in session['exercises']:
db.execute(
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
"VALUES (%s, %s, %s, %s, %s)",
[session_id, ex['id'], ex['sets'], ex['rep_range'], ex['order']],
commit=True
)
# TODO: Add routes for editing and assigning programs
flash(f"Program '{program_name}' updated successfully!", "success")
return redirect(url_for('programs.view_program', program_id=program_id))
except Exception as e:
print(f"Error updating program: {e}")
flash(f"Database error updating program: {e}", "error")
return redirect(url_for('programs.edit_program', program_id=program_id))
# GET Request
sessions = db.execute("SELECT * FROM program_session WHERE program_id = %s ORDER BY session_order", [program_id])
for session in sessions:
exercises = db.execute(
"""SELECT e.exercise_id, e.name, pse.sets, pse.rep_range, pse.exercise_order
FROM program_session_exercise pse
JOIN exercise e ON pse.exercise_id = e.exercise_id
WHERE pse.session_id = %s
ORDER BY pse.exercise_order""",
[session['session_id']]
)
session['exercises'] = exercises
all_exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
htmx_req = request.headers.get('HX-Request')
if htmx_req:
return render_block(current_app.jinja_env, 'program_edit.html', 'content',
program=program, sessions=sessions, exercises=all_exercises)
return render_template('program_edit.html', program=program, sessions=sessions, exercises=all_exercises)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -149,8 +149,9 @@
{% if day.has_workouts %}
<!-- Mobile Summary -->
<div
class="sm:hidden flex flex-col flex-grow text-[8px] text-gray-500 font-medium leading-tight overflow-hidden pb-1 space-y-0.5">
<div class="sm:hidden flex flex-col flex-grow text-[8px] text-gray-500 font-medium leading-tight overflow-hidden pb-1 space-y-0.5"
hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=day.workouts[0].workout_id) }}"
hx-push-url="true" hx-target="#container">
{% for name in day.exercise_names %}
<div class="truncate pl-0.5 border-l border-blue-200">{{ name }}</div>
{% endfor %}
@@ -163,11 +164,11 @@
hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.workout_id) }}"
hx-push-url="true" hx-target="#container">
{% for set in workout.sets %}
<div class="flex flex-col w-full px-0.5 text-[9px] lg:text-[10px] leading-tight mb-1">
<span class="truncate flex items-center min-w-0">
<div class="flex flex-col w-full px-0.5 leading-tight mb-1">
<span class="truncate flex items-center min-w-0 text-[14px] lg:text-[12px]">
<span class="truncate">{{ set.exercise_name }}</span>
</span>
<span class="font-light text-gray-400 text-[8px] lg:text-[9px] flex items-center">
<span class="font-light text-gray-400 text-[12px] lg:text-[9px] flex items-center">
<span>{{ set.repetitions }} x {{ set.weight }}kg</span>
{% if set.is_pr %}
<span class="ml-1 text-yellow-500 shrink-0 text-[8px]">🏆</span>

View File

@@ -96,7 +96,7 @@
{# 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">
<div class="exercise-row flex items-center space-x-4 bg-white p-2 rounded border border-gray-100 shadow-sm">
<div class="flex-grow relative">
{{ render_partial('partials/custom_select.html',
name='exercises_SESSION_INDEX_PLACEHOLDER',
@@ -106,6 +106,16 @@
placeholder='Select Exercise...')
}}
</div>
<div class="w-16">
<input type="number" name="sets_SESSION_INDEX_PLACEHOLDER" placeholder="Sets"
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
min="1" value="3">
</div>
<div class="w-24">
<input type="text" name="reps_SESSION_INDEX_PLACEHOLDER" placeholder="Reps (e.g. 8-10)"
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value="8-10">
</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">
@@ -180,10 +190,19 @@
function addExerciseSelect(container, sessionIndex) {
const newExFragment = exerciseTemplate.content.cloneNode(true);
const nativeSelect = newExFragment.querySelector('.native-select');
const setsInput = newExFragment.querySelector('input[name^="sets_"]');
const repsInput = newExFragment.querySelector('input[name^="reps_"]');
const removeBtn = newExFragment.querySelector('.remove-exercise-btn');
if (nativeSelect) {
nativeSelect.name = `exercises_${sessionIndex}`;
}
if (setsInput) {
setsInput.name = `sets_${sessionIndex}`;
}
if (repsInput) {
repsInput.name = `reps_${sessionIndex}`;
}
container.appendChild(newExFragment);
@@ -251,12 +270,22 @@
nameInput.name = `session_name_${newIndex}`;
}
// Update names for the exercise selects within this session
const exerciseSelects = row.querySelectorAll('.native-select'); // Target hidden selects
// Update names for the exercise selects and metadata within this session
const exerciseSelects = row.querySelectorAll('.native-select');
exerciseSelects.forEach(select => {
select.name = `exercises_${newIndex}`;
});
const setsInputs = row.querySelectorAll('input[name^="sets_"]');
setsInputs.forEach(input => {
input.name = `sets_${newIndex}`;
});
const repsInputs = row.querySelectorAll('input[name^="reps_"]');
repsInputs.forEach(input => {
input.name = `reps_${newIndex}`;
});
// Update listener for the "Add Exercise" button
const addExerciseBtn = row.querySelector('.add-exercise-btn');
if (addExerciseBtn) {

334
templates/program_edit.html Normal file
View File

@@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title %}Edit {{ program.name }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-3xl">
<h1 class="text-3xl font-bold mb-8 text-center text-gray-800">Edit Workout Program</h1>
<form method="POST" action="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
id="edit-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 value="{{ program.name }}"
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">{{ program.description or '' }}</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">
{% for session in sessions %}
{% set session_index = loop.index0 %}
<div class="session-row bg-gray-50 border border-gray-300 rounded-lg shadow-sm overflow-hidden"
data-index="{{ session_index }}">
{# 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.session_order
}}</h3>
<input type="hidden" name="session_order_{{ session_index }}"
value="{{ session.session_order }}">
<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 }}"
class="block text-sm font-medium text-gray-700 mb-1">Session Name (Optional):</label>
<input type="text" id="session_name_{{ session_index }}"
name="session_name_{{ session_index }}" value="{{ session.session_name or '' }}"
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">
{% for exercise in session.exercises %}
<div
class="exercise-row flex items-center space-x-4 bg-white p-2 rounded border border-gray-100 shadow-sm">
<div class="flex-grow relative">
{{ render_partial('partials/custom_select.html',
name='exercises_' ~ session_index,
options=exercises,
multiple=false,
search=true,
selected_values=[exercise.exercise_id],
placeholder='Select Exercise...')
}}
</div>
<div class="w-16">
<input type="number" name="sets_{{ session_index }}" placeholder="Sets"
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
min="1" value="{{ exercise.sets or 3 }}">
</div>
<div class="w-24">
<input type="text" name="reps_{{ session_index }}"
placeholder="Reps (e.g. 8-10)"
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value="{{ exercise.rep_range or '8-10' }}">
</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>
{% endfor %}
</div>
<button type="button" data-session-index="{{ session_index }}"
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>
{% endfor %}
</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-between pt-4 border-t border-gray-200">
<a href="{{ url_for('programs.view_program', program_id=program.program_id) }}"
hx-get="{{ url_for('programs.view_program', program_id=program.program_id) }}" hx-target="#container"
hx-push-url="true" class="text-gray-600 hover:text-gray-900 font-medium">Cancel</a>
<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">
Save Changes
</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-4 bg-white p-2 rounded border border-gray-100 shadow-sm">
<div class="flex-grow relative">
{{ render_partial('partials/custom_select.html',
name='exercises_SESSION_INDEX_PLACEHOLDER',
options=exercises,
multiple=false,
search=true,
placeholder='Select Exercise...')
}}
</div>
<div class="w-16">
<input type="number" name="sets_SESSION_INDEX_PLACEHOLDER" placeholder="Sets"
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
min="1" value="3">
</div>
<div class="w-24">
<input type="text" name="reps_SESSION_INDEX_PLACEHOLDER" placeholder="Reps (e.g. 8-10)"
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value="8-10">
</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>
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) return;
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 nameInput = newRow.querySelector('input[id^="session_name_"]');
if (nameInput) {
nameInput.id = `session_name_${currentSessionIndex}`;
nameInput.name = `session_name_${currentSessionIndex}`;
}
const addExerciseBtn = newRow.querySelector('.add-exercise-btn');
const exercisesContainer = newRow.querySelector('.session-exercises-container');
if (addExerciseBtn && exercisesContainer) {
addExerciseBtn.dataset.sessionIndex = currentSessionIndex;
addExerciseBtn.addEventListener('click', handleAddExerciseClick);
addExerciseSelect(exercisesContainer, currentSessionIndex);
}
sessionsContainer.appendChild(newRowFragment);
attachRemoveListener(newRow.querySelector('.remove-session-btn'));
sessionCounter++;
}
function addExerciseSelect(container, sessionIndex) {
const newExFragment = exerciseTemplate.content.cloneNode(true);
const nativeSelect = newExFragment.querySelector('.native-select');
const setsInput = newExFragment.querySelector('input[name^="sets_"]');
const repsInput = newExFragment.querySelector('input[name^="reps_"]');
const removeBtn = newExFragment.querySelector('.remove-exercise-btn');
if (nativeSelect) nativeSelect.name = `exercises_${sessionIndex}`;
if (setsInput) setsInput.name = `sets_${sessionIndex}`;
if (repsInput) repsInput.name = `reps_${sessionIndex}`;
container.appendChild(newExFragment);
attachExerciseRemoveListener(removeBtn);
}
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);
}
}
function attachRemoveListener(button) {
button.addEventListener('click', function () {
this.closest('.session-row').remove();
updateSessionNumbers();
});
}
function attachExerciseRemoveListener(button) {
if (button) {
button.addEventListener('click', function () {
this.closest('.exercise-row').remove();
});
}
}
function updateSessionNumbers() {
const rows = sessionsContainer.querySelectorAll('.session-row');
rows.forEach((row, index) => {
const newIndex = index;
const daySpan = row.querySelector('.session-day-number');
if (daySpan) daySpan.textContent = `Day ${newIndex + 1}`;
const orderInput = row.querySelector('input[type="hidden"]');
if (orderInput) {
orderInput.name = `session_order_${newIndex}`;
orderInput.value = newIndex + 1;
}
const nameInput = row.querySelector('input[id^="session_name_"]');
if (nameInput) {
nameInput.id = `session_name_${newIndex}`;
nameInput.name = `session_name_${newIndex}`;
}
row.querySelectorAll('.native-select').forEach(s => s.name = `exercises_${newIndex}`);
row.querySelectorAll('input[name^="sets_"]').forEach(i => i.name = `sets_${newIndex}`);
row.querySelectorAll('input[name^="reps_"]').forEach(i => i.name = `reps_${newIndex}`);
const addExerciseBtn = row.querySelector('.add-exercise-btn');
if (addExerciseBtn) addExerciseBtn.dataset.sessionIndex = newIndex;
row.dataset.index = newIndex;
});
sessionCounter = rows.length;
}
addSessionBtn.addEventListener('click', addSessionRow);
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 => {
btn.addEventListener('click', handleAddExerciseClick);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Import Program{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Import Workout Program</h1>
<p class="mt-1 text-sm text-gray-500">
Upload a JSON file containing your program structure, sessions, and sets/reps metadata.
</p>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<form action="{{ url_for('programs.import_program') }}" method="POST" enctype="multipart/form-data"
class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700">Program JSON File</label>
<div
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-indigo-400 transition-colors">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none"
viewBox="0 0 48 48" aria-hidden="true">
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-gray-600">
<label for="file-upload"
class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<span>Upload a file</span>
<input id="file-upload" name="file" type="file" accept=".json" class="sr-only"
required onchange="updateFileName(this)">
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">JSON file up to 10MB</p>
<p id="file-name" class="mt-2 text-sm text-indigo-600 font-semibold"></p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<a href="{{ url_for('programs.list_programs') }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</a>
<button type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Upload and Import
</button>
</div>
</form>
</div>
</div>
<div class="mt-8 bg-indigo-50 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-indigo-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-indigo-800">JSON Format Requirement</h3>
<div class="mt-2 text-sm text-indigo-700">
<p>The JSON file should follow the shared schema, including <code>program_name</code>,
<code>description</code>, and a <code>sessions</code> array with <code>exercises</code>.
Each exercise should have <code>id</code>, <code>name</code>, <code>sets</code>,
<code>rep_range</code>, and <code>order</code>.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function updateFileName(input) {
const fileName = input.files[0] ? input.files[0].name : '';
document.getElementById('file-name').textContent = fileName;
}
</script>
{% endblock %}

View File

@@ -6,10 +6,17 @@
<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 class="flex space-x-2">
<a href="{{ url_for('programs.import_program') }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
hx-get="{{ url_for('programs.import_program') }}" hx-target="#container" hx-push-url="true">
Import from JSON
</a>
<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>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
@@ -40,12 +47,17 @@
<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>
{# Edit Button #}
<a href="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
class="text-indigo-600 hover:text-indigo-900"
hx-get="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
hx-target="#container" hx-push-url="true" hx-swap="innerHTML">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</a>
{# 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) }}"
@@ -60,15 +72,27 @@
</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 class="mt-2 text-sm text-gray-500">
<p class="mb-3">{{ program.description | default('No description provided.') }}</p>
{% if program.sessions %}
<div class="flex flex-wrap gap-2 mt-2">
{% for session in program.sessions %}
<div
class="bg-gray-50 border border-gray-200 rounded p-2 text-xs min-w-[120px] max-w-[180px]">
<p class="font-bold text-gray-700 mb-1">
Day {{ session.session_order }}{% if session.session_name %}: {{
session.session_name }}{% endif %}
</p>
<ul class="list-disc list-inside text-gray-600 space-y-0.5">
{% for exercise in session.exercises %}
<li class="truncate" title="{{ exercise.name }}">{{ exercise.name }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</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> #}
{% endif %}
</div>
</div>
</a>

View File

@@ -25,7 +25,19 @@
{{ program.description }}
</p>
{% endif %}
{# Add Edit/Assign buttons here later #}
<div class="mt-4 flex space-x-3">
<a href="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
hx-get="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
hx-target="#container" hx-push-url="true" hx-swap="innerHTML">
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-2 h-5 w-5 text-gray-400" viewBox="0 0 20 20"
fill="currentColor">
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Edit Program
</a>
</div>
</div>
</div>
@@ -39,32 +51,56 @@
<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">
<div class="py-4 sm:py-5 sm:px-6">
<dt class="text-sm font-medium text-gray-500 mb-2">
Exercises
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<dd class="mt-1 text-sm text-gray-900">
{% 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>
<div class="overflow-x-auto border border-gray-200 rounded-md">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Order</th>
<th scope="col"
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Exercise</th>
<th scope="col"
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Sets</th>
<th scope="col"
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rep Range</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for exercise in session.exercises %}
<tr>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ loop.index if not exercise.exercise_order else
exercise.exercise_order }}
</td>
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">
{{ exercise.name }}
</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ exercise.sets if exercise.sets else '-' }}
</td>
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
{{ exercise.rep_range if exercise.rep_range else '-' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500 italic">No exercises found for this session's tag filter.</p>
<p class="text-gray-500 italic">No exercises found for this session.</p>
{% endif %}
</dd>
</div>