Compare commits

...

49 Commits

Author SHA1 Message Date
Peter Stockings
ff6a921550 Improve styling of exercise history/sparkline partials 2026-02-28 16:33:43 +11:00
Peter Stockings
57f7610963 Remove requirements.txt 2026-02-27 12:39:13 +11:00
Peter Stockings
a401c1a1ab Switch to UV 2026-02-27 12:36:40 +11:00
Peter Stockings
b0b42c0d77 Migrate from psycopg2 to psycopg3 to fix SIGSEGV crashes 2026-02-27 12:06:38 +11:00
Peter Stockings
89d0a7fb12 Switch to gevent workers to prevent SIGSEGV 2026-02-27 11:57:41 +11:00
Peter Stockings
37e56559a9 Fix python version formatting 2026-02-27 11:49:11 +11:00
Peter Stockings
d9def5c6b6 Add .python-version set to 3.14.0 2026-02-27 11:47:17 +11:00
Peter Stockings
ccb71c37a4 Update remaining Flask extensions to versions compatible with Flask 3 2026-02-27 11:46:02 +11:00
Peter Stockings
7aebf8284d Update flask-htmx to fix Flask 3 dependency conflict and remove deprecated runtime.txt 2026-02-27 11:43:03 +11:00
Peter Stockings
28b542e618 Bump up to new python buildpack 2026-02-27 11:28:04 +11:00
Peter Stockings
fb07c1d8ed Bump up python version and packages 2026-02-27 11:26:09 +11:00
Peter Stockings
1c51bb6ced Remove dark TW classes from exercise history table 2026-02-27 10:29:54 +11:00
Peter Stockings
c4feaa97dd Add remember me cookie so users dont have to login each workout 2026-02-26 23:59:34 +11:00
Peter Stockings
73e02a7b12 Show exercise weekly/monthly progress on exercise history table 2026-02-26 23:53:47 +11:00
Peter Stockings
b31ab97cd4 Allow users to toggle to progress table view when selecting an exercise 2026-02-26 23:48:08 +11:00
Peter Stockings
895b813a35 Add button to topsets to show history as a table 2026-02-26 23:26:49 +11:00
Peter Stockings
67009c9603 Add search for activity logs 2026-02-13 00:28:12 +11:00
Peter Stockings
8c08140ad0 Move settings endpoints to separate blueprint 2026-02-08 18:48:03 +11:00
Peter Stockings
31078b181a Split up settings sub pages into templates loaded by htmx to reduce initial load 2026-02-08 18:25:43 +11:00
Peter Stockings
a6eca1b4ac Add ability to add/update/delete exercise categories 2026-02-08 16:48:47 +11:00
Peter Stockings
ce28f7f749 Add exercise category search in settings 2026-02-08 16:31:05 +11:00
Peter Stockings
31f738cfb3 Improve look of exercise list in settings page 2026-02-08 16:23:31 +11:00
Peter Stockings
0cd74f7207 Create blueprint for exercises 2026-02-08 16:08:30 +11:00
Peter Stockings
ef91dc1fe4 Changel LLM model gemini-2.0-flash -> gemini-2.5-flash-lite 2026-02-08 14:51:11 +11:00
Peter Stockings
a9f3dd4a38 Embed achievement badges in exercise progress graphs 2026-02-06 00:20:12 +11:00
Peter Stockings
3f3725d277 Improve look of SQL explorer page, and improve validation of exercise selection in workouts 2026-02-04 12:37:05 +11:00
Peter Stockings
09d90b5a1e Round person graph models to one decimal place to reduce svg size 2026-02-04 09:54:03 +11:00
Peter Stockings
3fabde145d Preload csss & js, add skeleton graphs for loading 2026-02-04 09:53:35 +11:00
Peter Stockings
71a5ae590e Add brotli complression, cache graph requests for 5mins and add pagination for person overview 2026-02-04 09:28:18 +11:00
Peter Stockings
b4121eada7 Add database connection pooling 2026-02-04 00:03:03 +11:00
Peter Stockings
a6a71f3139 Only load graphs when they come into view 2026-02-03 23:52:59 +11:00
Peter Stockings
9998616946 Add defer to hyperscript 2026-02-03 23:52:21 +11:00
Peter Stockings
c20f2e2f85 Added safety checks to the graph regression logic in utils.py. This stops those "illegal value" server warnings and makes the math more efficient for small datasets 2026-02-03 23:51:52 +11:00
Peter Stockings
ec8d7f6825 Add asset caching 2026-02-03 23:36:58 +11:00
Peter Stockings
2e79ad1b8b Remove more unused js and css 2026-02-03 23:36:30 +11:00
Peter Stockings
d223bdeebc Add compression 2026-02-03 23:25:13 +11:00
Peter Stockings
9a2ce6754a Remove unused js libs 2026-02-03 23:24:49 +11:00
Peter Stockings
afc5749c82 Reduce size of logo 2026-02-03 23:08:45 +11:00
Peter Stockings
2d1509a0cd Remove dependencies on external fonts 2026-02-03 23:03:29 +11:00
Peter Stockings
83c3cd83a6 Remove SweetAlert 2 library 2026-02-03 22:55:12 +11:00
Peter Stockings
db8d39d1eb Fix issue where adding a set would result in two 'Topset added' notification 2026-02-03 22:51:19 +11:00
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
Peter Stockings
f53bf3d106 Improve monthly calendar view 2026-02-02 21:51:32 +11:00
Peter Stockings
2b330e4743 Add acheivement badges to monthly calendar view 2026-02-02 20:54:28 +11:00
Peter Stockings
bc2a350e90 Show monthly stats in calendar view 2026-02-01 10:55:28 +11:00
Peter Stockings
a59cef5c95 Add missing entries to changelog 2026-01-31 15:06:23 +11:00
Peter Stockings
d7c9f71d22 Add activity logs table 2026-01-31 14:53:01 +11:00
66 changed files with 3944 additions and 3522 deletions

View File

@@ -1,2 +1,2 @@
heroku/nodejs
https://github.com/heroku/heroku-buildpack-python#archive/v210
https://github.com/heroku/heroku-buildpack-python

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14.0

155
app.py
View File

@@ -1,5 +1,11 @@
from datetime import date
import os
from dotenv import load_dotenv
# Load environment variables from .env file in non-production environments
if os.environ.get('FLASK_ENV') != 'production':
load_dotenv()
from datetime import date
from flask import Flask, abort, render_template, redirect, request, url_for
from flask_login import LoginManager, login_required, current_user
import jinja_partials
@@ -16,18 +22,23 @@ 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 routes.exercises import exercises_bp # Import the new exercises blueprint
from routes.settings import settings_bp # Import the new settings blueprint
from extensions import db
from utils import convert_str_to_date
from flask_htmx import HTMX
import minify_html
import os
from dotenv import load_dotenv
# Load environment variables from .env file in non-production environments
if os.environ.get('FLASK_ENV') != 'production':
load_dotenv()
from flask_compress import Compress
from flask_caching import Cache
app = Flask(__name__)
app.config['COMPRESS_REGISTER'] = True
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 31536000 # 1 year
app.config['CACHE_TYPE'] = 'SimpleCache'
app.config['CACHE_DEFAULT_TIMEOUT'] = 300 # 5 minutes
Compress(app)
cache = Cache(app)
app.config.from_pyfile('config.py')
app.secret_key = os.environ.get('SECRET_KEY', '2a661781919643cb8a5a8bc57642d99f')
jinja_partials.register_extensions(app)
@@ -62,6 +73,8 @@ app.register_blueprint(endpoints_bp) # Register the endpoints blueprint (prefix
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.register_blueprint(exercises_bp) # Register the exercises blueprint
app.register_blueprint(settings_bp) # Register the settings blueprint
@app.after_request
def response_minify(response):
@@ -136,7 +149,10 @@ def person_overview(person_id):
if not selected_exercise_ids and htmx.trigger_name != 'exercise_id':
selected_exercise_ids = db.person_overview.list_of_performed_exercise_ids(person_id, min_date, max_date)
person = db.person_overview.get(person_id, min_date, max_date, selected_exercise_ids)
limit = request.args.get('limit', type=int, default=20)
offset = request.args.get('offset', type=int, default=0)
person = db.person_overview.get(person_id, min_date, max_date, selected_exercise_ids, limit=limit, offset=offset)
exercises = db.person_overview.get_exercises_with_selection(person_id, min_date, max_date, selected_exercise_ids)
tags = db.get_tags_for_person(person_id)
@@ -147,10 +163,15 @@ def person_overview(person_id):
"tags": tags,
"selected_exercise_ids": selected_exercise_ids,
"max_date": max_date,
"min_date": min_date
"min_date": min_date,
"limit": limit,
"offset": offset,
"next_offset": offset + limit
}
if htmx:
if htmx.target == 'load-more-row':
return render_template('partials/workout_rows.html', **render_args)
return render_block(app.jinja_env, 'person_overview.html', 'content', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
@@ -160,6 +181,7 @@ def person_overview(person_id):
def create_person():
name = request.form.get("name")
new_person_id = db.create_person(name)
db.activityRequest.log(current_user.id, 'CREATE_PERSON', 'person', new_person_id, f"Created person: {name}")
return render_template('partials/person.html', person_id=new_person_id, name=name), 200, {"HX-Trigger": "updatedPeople"}
@@ -168,7 +190,9 @@ def create_person():
@admin_required
@validate_person
def delete_person(person_id):
name = db.get_person_name(person_id)
db.delete_person(person_id)
db.activityRequest.log(current_user.id, 'DELETE_PERSON', 'person', person_id, f"Deleted person: {name}")
return "", 200, {"HX-Trigger": "updatedPeople"}
@@ -187,7 +211,9 @@ def get_person_edit_form(person_id):
@require_ownership
def update_person_name(person_id):
new_name = request.form.get("name")
old_name = db.get_person_name(person_id)
db.update_person_name(person_id, new_name)
db.activityRequest.log(current_user.id, 'UPDATE_PERSON_NAME', 'person', person_id, f"Updated name for {old_name} to {new_name}")
return render_template('partials/person.html', person_id=person_id, name=new_name), 200, {"HX-Trigger": "updatedPeople"}
@@ -197,87 +223,10 @@ def get_person_name(person_id):
return render_template('partials/person.html', person_id=person_id, name=name)
@ app.route("/exercise", methods=['POST'])
@login_required
def create_exercise():
name = request.form.get("name")
attribute_ids = request.form.getlist('attribute_ids')
exercise = db.create_exercise(name, attribute_ids)
return render_template('partials/exercise.html',
exercise_id=exercise['exercise_id'],
name=exercise['name'],
attributes=exercise['attributes'])
@ app.route("/exercise/<int:exercise_id>", methods=['GET'])
def get_exercise(exercise_id):
exercise = db.get_exercise(exercise_id)
return render_template('partials/exercise.html',
exercise_id=exercise_id,
name=exercise['name'],
attributes=exercise['attributes'])
@ app.route("/exercise/<int:exercise_id>/edit_form", methods=['GET'])
@login_required
def get_exercise_edit_form(exercise_id):
exercise = db.get_exercise(exercise_id)
all_attributes = db.exercises.get_attributes_by_category()
# Format options for custom_select
formatted_options = {}
ex_attr_ids = [a['attribute_id'] for a in exercise['attributes']]
for cat, attrs in all_attributes.items():
formatted_options[cat] = [
{
"id": a['attribute_id'],
"name": a['name'],
"selected": a['attribute_id'] in ex_attr_ids
} for a in attrs
]
return render_template('partials/exercise.html',
exercise_id=exercise_id,
name=exercise['name'],
attributes=exercise['attributes'],
all_attributes=formatted_options,
is_edit=True)
@ app.route("/exercise/<int:exercise_id>/update", methods=['PUT'])
@login_required
def update_exercise(exercise_id):
new_name = request.form.get('name')
attribute_ids = request.form.getlist('attribute_ids')
exercise = db.update_exercise(exercise_id, new_name, attribute_ids)
return render_template('partials/exercise.html',
exercise_id=exercise_id,
name=exercise['name'],
attributes=exercise['attributes'])
""" @ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
def delete_exercise(exercise_id):
db.delete_exercise(exercise_id)
return "" """
@ app.route("/settings")
@ login_required
def settings():
people = db.get_people()
exercises = db.get_all_exercises()
all_attributes = db.exercises.get_attributes_by_category()
# Format options for custom_select
formatted_options = {}
for cat, attrs in all_attributes.items():
formatted_options[cat] = [{"id": a['attribute_id'], "name": a['name']} for a in attrs]
if htmx:
return render_block(app.jinja_env, "settings.html", "content",
people=people, exercises=exercises, all_attributes=formatted_options), 200, {"HX-Trigger": "updatedPeople"}
return render_template('settings.html', people=people, exercises=exercises, all_attributes=formatted_options)
# Routes moved to routes/tags.py blueprint
@@ -302,6 +251,7 @@ def get_exercise_progress_for_user(person_id, exercise_id):
return render_template('partials/sparkline.html', **exercise_progress)
@app.route("/stats", methods=['GET'])
@cache.cached(timeout=300, query_string=True)
def get_stats():
selected_people_ids = request.args.getlist('person_id', type=int)
min_date = request.args.get('min_date', type=convert_str_to_date)
@@ -311,6 +261,7 @@ def get_stats():
return render_template('partials/stats.html', stats=stats, refresh_url=request.full_path)
@app.route("/graphs", methods=['GET'])
@cache.cached(timeout=300, query_string=True)
def get_people_graphs():
selected_people_ids = request.args.getlist('person_id', type=int)
min_date = request.args.get('min_date', type=convert_str_to_date)
@@ -321,39 +272,7 @@ def get_people_graphs():
return render_template('partials/people_graphs.html', graphs=graphs, refresh_url=request.full_path)
@app.route("/exercises/get")
def get_exercises():
query = request.args.get('query')
person_id = request.args.get('person_id', type=int)
exercises = db.exercises.get(query)
return render_template('partials/exercise/exercise_dropdown.html', exercises=exercises, person_id=person_id)
@app.route("/exercise/<int:exercise_id>/edit_name", methods=['GET', 'POST'])
@login_required
def edit_exercise_name(exercise_id):
exercise = db.exercises.get_exercise(exercise_id)
person_id = request.args.get('person_id', type=int)
if request.method == 'GET':
return render_template('partials/exercise/edit_exercise_name.html', exercise=exercise, person_id=person_id)
else:
updated_name = request.form['name']
updated_exercise = db.exercises.update_exercise_name(exercise_id, updated_name)
return render_template('partials/exercise/exercise_list_item.html', exercise=updated_exercise, person_id=person_id)
@app.route("/exercises/add", methods=['POST'])
@login_required
def add_exercise():
exercise_name = request.form['query']
new_exercise = db.exercises.add_exercise(exercise_name)
person_id = request.args.get('person_id', type=int)
return render_template('partials/exercise/exercise_list_item.html', exercise=new_exercise, person_id=person_id)
@ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
@login_required
@admin_required
def delete_exercise(exercise_id):
db.exercises.delete_exercise(exercise_id)
return ""
@app.teardown_appcontext
def closeConnection(exception):

82
db.py
View File

@@ -1,20 +1,24 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
import psycopg
from psycopg_pool import ConnectionPool
from psycopg.rows import dict_row
from datetime import datetime
from dateutil.relativedelta import relativedelta
from urllib.parse import urlparse
from flask import g
from flask import g, current_app
from features.exercises import Exercises
from features.people_graphs import PeopleGraphs
from features.person_overview import PersonOverview
from features.stats import Stats
from features.dashboard import Dashboard
from features.schema import Schema
from features.activity import Activity
from utils import get_exercise_graph_model
class DataBase():
_pool = None
def __init__(self, app=None):
self.stats = Stats(self.execute)
self.exercises = Exercises(self.execute)
@@ -22,34 +26,34 @@ class DataBase():
self.people_graphs = PeopleGraphs(self.execute)
self.dashboard = Dashboard(self.execute)
self.schema = Schema(self.execute)
self.activityRequest = Activity(self.execute)
db_url = urlparse(os.environ['DATABASE_URL'])
# if db_url is null then throw error
if not db_url:
if not os.environ.get('DATABASE_URL'):
raise Exception("No DATABASE_URL environment variable set")
def getDB(self):
db = getattr(g, 'database', None)
if db is None:
db_url = urlparse(os.environ['DATABASE_URL'])
g.database = psycopg2.connect(
database=db_url.path[1:],
user=db_url.username,
password=db_url.password,
host=db_url.hostname,
port=db_url.port
if DataBase._pool is None:
# Note: psycopg3 ConnectionPool takes a conninfo string directly, not parsed kwargs
DataBase._pool = ConnectionPool(
conninfo=os.environ['DATABASE_URL'],
min_size=1,
max_size=20
)
db = g.database
return db
def close_connection(exception):
db = getattr(g, 'database', None)
def getDB(self):
if 'database' not in g:
g.database = self._pool.getconn()
return g.database
def close_connection(self, exception=None):
db = g.pop('database', None)
if db is not None:
db.close()
db.rollback()
self._pool.putconn(db)
def execute(self, query, args=(), one=False, commit=False):
conn = self.getDB()
cur = conn.cursor(cursor_factory=RealDictCursor)
cur = conn.cursor(row_factory=dict_row)
# Convert any custom placeholders from %s to standard %s format used by psycopg3
cur.execute(query, args)
rv = None
if cur.description is not None:
@@ -375,6 +379,28 @@ class DataBase():
return None
else:
return (topset.get('repetitions'), topset.get('weight'), topset['exercise_name'])
def get_recent_topsets_for_exercise(self, person_id, exercise_id, limit=5, offset=0):
topsets = self.execute("""
SELECT
t.topset_id,
t.repetitions,
t.weight,
w.start_date,
w.workout_id,
e.name AS "exercise_name"
FROM
exercise e
JOIN topset t ON e.exercise_id = t.exercise_id
JOIN workout w ON t.workout_id = w.workout_id
WHERE
e.exercise_id = %s AND w.person_id = %s
ORDER BY
w.start_date DESC, t.topset_id DESC
LIMIT %s OFFSET %s;
""", [exercise_id, person_id, limit, offset])
return topsets
def get_all_exercises(self):
return self.exercises.get("")
@@ -404,8 +430,8 @@ class DataBase():
WHERE
W.person_id = %s
AND E.exercise_id = %s AND
(%s IS NULL OR W.start_date >= %s) AND
(%s IS NULL OR W.start_date <= %s)
(%s::date IS NULL OR W.start_date >= %s::date) AND
(%s::date IS NULL OR W.start_date <= %s::date)
ORDER BY
W.start_date;
""", [person_id, exercise_id, min_date, min_date, max_date, max_date])
@@ -422,6 +448,11 @@ class DataBase():
start_dates = [t['start_date'] for t in topsets]
messages = [f'{t["repetitions"]} x {t["weight"]}kg ({t["estimated_1rm"]}kg E1RM) on {t["start_date"].strftime("%d %b %y")}' for t in topsets]
# Get the latest topset info for badges
latest_topset = topsets[-1]
latest_topset_id = latest_topset['topset_id']
latest_workout_id = latest_topset['workout_id']
exercise_progress = get_exercise_graph_model(
exercise_name,
estimated_1rm,
@@ -435,6 +466,9 @@ class DataBase():
min_date,
max_date,
degree)
exercise_progress['latest_topset_id'] = latest_topset_id
exercise_progress['latest_workout_id'] = latest_workout_id
return exercise_progress

60
features/activity.py Normal file
View File

@@ -0,0 +1,60 @@
from flask import request, current_app
from utils import get_client_ip
class Activity:
def __init__(self, db_connection_method):
self.execute = db_connection_method
def log(self, person_id, action, entity_type=None, entity_id=None, details=None):
"""Records an action in the activity_log table."""
try:
ip_address = get_client_ip()
user_agent = request.user_agent.string if request else None
sql = """
INSERT INTO activity_log (person_id, action, entity_type, entity_id, details, ip_address, user_agent)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
self.execute(sql, [person_id, action, entity_type, entity_id, details, ip_address, user_agent], commit=True)
except Exception as e:
# We don't want logging to break the main application flow
current_app.logger.error(f"Error logging activity: {e}")
def get_recent_logs(self, limit=50, offset=0, search_query=None):
"""Fetches recent activity logs with person names, supporting pagination and search."""
params = [limit, offset]
search_clause = ""
if search_query:
# Add wildcard percentages for partial matching
term = f"%{search_query}%"
search_clause = """
WHERE
p.name ILIKE %s OR
al.action ILIKE %s OR
al.entity_type ILIKE %s OR
al.details ILIKE %s
"""
# Prepend search terms to params list (limit/offset must change position if we were using ? placeholders
# but with %s list, order matters. Let's reconstruct consistent order).
# Actually, LIMIT/OFFSET are at the end. Search params come before.
params = [term, term, term, term, limit, offset]
query = f"""
SELECT
al.id,
al.person_id,
p.name as person_name,
al.action,
al.entity_type,
al.entity_id,
al.details,
al.ip_address,
al.user_agent,
al.timestamp
FROM activity_log al
LEFT JOIN person p ON al.person_id = p.person_id
{search_clause}
ORDER BY al.timestamp DESC
LIMIT %s OFFSET %s
"""
return self.execute(query, params)

View File

@@ -3,16 +3,42 @@ class Exercises:
self.execute = db_connection_method
def get(self, query):
# Add wildcards to the query
search_query = f"%{query}%"
# We need to fetch exercises with their attributes.
# Since an exercise can have many attributes, we'll fetch basic info first or use a join.
# But wait, the settings page just lists names. We can fetch attributes separately for each row or do a group_concat-like join.
# However, for the settings list, we want to show the tags.
# Let's use a simpler approach: fetch exercises and then for each one (or via a single join) get attributes.
exercises = self.execute("SELECT exercise_id, name FROM exercise WHERE LOWER(name) LIKE LOWER(%s) ORDER BY name ASC;", [search_query])
if not query:
exercises = self.execute("SELECT exercise_id, name FROM exercise ORDER BY name ASC;")
for ex in exercises:
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
return exercises
# Check for category:value syntax
if ':' in query:
category_part, value_part = query.split(':', 1)
category_part = f"%{category_part.strip().lower()}%"
value_part = f"%{value_part.strip().lower()}%"
query = """
SELECT DISTINCT e.exercise_id, e.name
FROM exercise e
JOIN exercise_to_attribute eta ON e.exercise_id = eta.exercise_id
JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
JOIN exercise_attribute_category cat ON attr.category_id = cat.category_id
WHERE LOWER(cat.name) LIKE LOWER(%s) AND LOWER(attr.name) LIKE LOWER(%s)
ORDER BY e.name ASC;
"""
exercises = self.execute(query, [category_part, value_part])
else:
# Fallback: search in name OR attribute name
search_term = query.strip().lower()
search_query = f"%{search_term}%"
query = """
SELECT DISTINCT e.exercise_id, e.name
FROM exercise e
LEFT JOIN exercise_to_attribute eta ON e.exercise_id = eta.exercise_id
LEFT JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
WHERE LOWER(e.name) LIKE LOWER(%s) OR LOWER(attr.name) LIKE LOWER(%s)
ORDER BY e.name ASC;
"""
exercises = self.execute(query, [search_query, search_query])
for ex in exercises:
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
@@ -121,3 +147,38 @@ class Exercises:
return distribution
# Category Management
def add_category(self, name):
result = self.execute('INSERT INTO exercise_attribute_category (name) VALUES (%s) RETURNING category_id, name', [name], commit=True, one=True)
return result
def update_category(self, category_id, name):
self.execute('UPDATE exercise_attribute_category SET name = %s WHERE category_id = %s', [name, category_id], commit=True)
return {"category_id": category_id, "name": name}
def delete_category(self, category_id):
# First delete all attributes in this category
attributes = self.execute('SELECT attribute_id FROM exercise_attribute WHERE category_id = %s', [category_id])
for attr in attributes:
self.delete_attribute(attr['attribute_id'])
self.execute('DELETE FROM exercise_attribute_category WHERE category_id = %s', [category_id], commit=True)
# Attribute Management
def add_attribute(self, name, category_id):
result = self.execute('INSERT INTO exercise_attribute (name, category_id) VALUES (%s, %s) RETURNING attribute_id, name, category_id', [name, category_id], commit=True, one=True)
return result
def update_attribute(self, attribute_id, name, category_id=None):
if category_id:
self.execute('UPDATE exercise_attribute SET name = %s, category_id = %s WHERE attribute_id = %s', [name, category_id, attribute_id], commit=True)
else:
self.execute('UPDATE exercise_attribute SET name = %s WHERE attribute_id = %s', [name, attribute_id], commit=True)
return self.execute('SELECT attribute_id, name, category_id FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], one=True)
def delete_attribute(self, attribute_id):
# Remove from all exercises first
self.execute('DELETE FROM exercise_to_attribute WHERE attribute_id = %s', [attribute_id], commit=True)
# Delete the attribute
self.execute('DELETE FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], commit=True)

View File

@@ -77,11 +77,33 @@ class PersonOverview:
return exercises
def get(self, person_id, start_date, end_date, selected_exercise_ids):
def get(self, person_id, start_date, end_date, selected_exercise_ids, limit=20, offset=0):
# Build placeholders for exercise IDs
placeholders = ", ".join(["%s"] * len(selected_exercise_ids))
exercise_placeholders = ", ".join(["%s"] * len(selected_exercise_ids))
# Dynamically inject placeholders into the query
# 1. Fetch workout IDs first for pagination
# We need to filter by person, date, and selected exercises
workout_ids_query = f"""
SELECT DISTINCT w.workout_id, w.start_date
FROM workout w
JOIN topset t ON w.workout_id = t.workout_id
WHERE w.person_id = %s
AND w.start_date BETWEEN %s AND %s
AND t.exercise_id IN ({exercise_placeholders})
ORDER BY w.start_date DESC
LIMIT %s OFFSET %s
"""
params = [person_id, start_date, end_date] + selected_exercise_ids + [limit + 1, offset]
workout_id_results = self.execute(workout_ids_query, params)
if not workout_id_results:
return {"person_id": person_id, "person_name": None, "workouts": [], "selected_exercises": [], "exercise_progress_graphs": [], "has_more": False}
has_more = len(workout_id_results) > limit
target_workout_ids = [r["workout_id"] for r in workout_id_results[:limit]]
workout_id_placeholders = ", ".join(["%s"] * len(target_workout_ids))
# 2. Fetch all details for these specific workouts
sql_query = f"""
SELECT
p.person_id,
@@ -103,19 +125,18 @@ class PersonOverview:
JOIN
exercise e ON t.exercise_id = e.exercise_id
WHERE
p.person_id = %s
AND w.start_date BETWEEN %s AND %s
AND e.exercise_id IN ({placeholders})
w.workout_id IN ({workout_id_placeholders})
AND e.exercise_id IN ({exercise_placeholders})
ORDER BY
w.start_date DESC, e.exercise_id ASC, t.topset_id ASC;
"""
# Add parameters for the query
params = [person_id, start_date, end_date] + selected_exercise_ids
# Parameters for the detailed query
params = target_workout_ids + selected_exercise_ids
result = self.execute(sql_query, params)
if not result:
return {"person_id": person_id, "person_name": None, "workouts": [], "selected_exercises": [], "exercise_progress_graphs": []}
return {"person_id": person_id, "person_name": None, "workouts": [], "selected_exercises": [], "exercise_progress_graphs": [], "has_more": False}
# Extract person info from the first row
person_info = {"person_id": result[0]["person_id"], "person_name": result[0]["person_name"]}
@@ -132,7 +153,6 @@ class PersonOverview:
exercises = sorted(exercises, key=lambda ex: ex["name"])
# Initialize the table structure
workouts = []
workout_map = {} # Map to track workouts
# Initialize the exercise sets dictionary
@@ -153,10 +173,11 @@ class PersonOverview:
# Add topset to the corresponding exercise
if row["exercise_id"] and row["topset_id"]:
# Add to workout exercises
workout_map[workout_id]["exercises"][row["exercise_id"]].append({
"repetitions": row["repetitions"],
"weight": row["weight"]
})
if row["exercise_id"] in workout_map[workout_id]["exercises"]:
workout_map[workout_id]["exercises"][row["exercise_id"]].append({
"repetitions": row["repetitions"],
"weight": row["weight"]
})
# Add to the exercise sets dictionary with workout start date
exercise_sets[row["exercise_id"]]["sets"].append({
@@ -167,9 +188,8 @@ class PersonOverview:
"exercise_name": row["exercise_name"]
})
# Transform into a list of rows
for workout_id, workout in workout_map.items():
workouts.append(workout)
# Transform into a list of rows, maintaining DESC order
workouts = [workout_map[wid] for wid in target_workout_ids if wid in workout_map]
exercise_progress_graphs = self.generate_exercise_progress_graphs(person_info["person_id"], exercise_sets)
@@ -177,7 +197,8 @@ class PersonOverview:
**person_info,
"workouts": workouts,
"selected_exercises": exercises,
"exercise_progress_graphs": exercise_progress_graphs
"exercise_progress_graphs": exercise_progress_graphs,
"has_more": has_more
}
def generate_exercise_progress_graphs(self, person_id, exercise_sets):

6
main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from workout!")
if __name__ == "__main__":
main()

32
pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[project]
name = "workout"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14.0"
dependencies = [
"brotli==1.0.9",
"email-validator==2.2.0",
"flask>=3.0.0",
"flask-bcrypt>=1.0.1",
"flask-caching>=2.1.0",
"flask-compress>=1.14",
"flask-htmx>=0.4.0",
"flask-login>=0.6.3",
"flask-wtf>=1.2.1",
"gunicorn>=21.2.0",
"jinja-partials==0.1.1",
"jinja2>=3.1.0",
"jinja2-fragments==0.3.0",
"minify-html>=0.15.0",
"numpy>=1.26.0",
"polars>=0.20.0",
"psycopg-pool>=3.2.0",
"psycopg[binary]>=3.0.0",
"pyarrow>=14.0.0",
"python-dateutil==2.8.2",
"python-dotenv==1.0.1",
"requests>=2.31.0",
"werkzeug>=3.0.0",
"wtforms>=3.1.0",
]

View File

@@ -1,20 +0,0 @@
Flask==2.2.2
gunicorn==19.7.1
Jinja2==3.1.0
jinja-partials==0.1.1
psycopg2-binary==2.9.3
flask-htmx==0.2.0
python-dateutil==2.8.2
minify-html==0.10.3
jinja2-fragments==0.3.0
Werkzeug==2.2.2
numpy==1.19.5
python-dotenv==1.0.1
wtforms==3.2.1
flask-wtf==1.2.2
Flask-Login==0.6.3
Flask-Bcrypt==1.0.1
email-validator==2.2.0
requests==2.26.0
polars>=0.20.0
pyarrow>=14.0.0

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import login_user, login_required, logout_user
from flask_login import login_user, login_required, logout_user, current_user
from forms.login import LoginForm
from forms.signup import SignupForm
from extensions import db
@@ -105,11 +105,12 @@ def signup():
form = SignupForm()
if form.validate_on_submit():
hashed_password = generate_password_hash(form.password.data)
create_person(
new_person_id = create_person(
name=form.name.data,
email=form.email.data,
password_hash=hashed_password
)
db.activityRequest.log(new_person_id, 'SIGNUP', 'person', new_person_id, f"User signed up: {form.email.data}")
flash("Account created successfully. Please log in.", "success")
return redirect(url_for('auth.login'))
return render_template('auth/signup.html', form=form)
@@ -121,12 +122,12 @@ def login():
if form.validate_on_submit():
person = get_person_by_email(form.email.data)
if person and check_password_hash(person.password_hash, form.password.data):
login_user(person)
record_login_attempt(form.email.data, True, person.id)
login_user(person, remember=True)
db.activityRequest.log(person.id, 'LOGIN_SUCCESS', 'person', person.id, f"User logged in: {form.email.data}")
flash("Logged in successfully.", "success")
return redirect(url_for('calendar.get_calendar', person_id=person.id))
else:
record_login_attempt(form.email.data, False, person.id if person else None)
db.activityRequest.log(person.id if person else None, 'LOGIN_FAILURE', 'person', person.id if person else None, f"Failed login attempt for: {form.email.data}")
flash("Invalid email or password.", "danger")
return render_template('auth/login.html', form=form)
@@ -134,6 +135,8 @@ def login():
@auth.route('/logout')
@login_required
def logout():
person_id = current_user.id if current_user.is_authenticated else None
logout_user()
db.activityRequest.log(person_id, 'LOGOUT', 'person', person_id, "User logged out")
flash('You have been logged out.', 'success')
return redirect(url_for('auth.login'))

View File

@@ -36,25 +36,44 @@ def _fetch_workout_data(db_executor, person_id, start_date, end_date, include_de
"""Fetches workout data for a person within a date range."""
if include_details:
query = """
SELECT
w.workout_id,
w.start_date,
t.topset_id,
t.repetitions,
t.weight,
e.name AS exercise_name,
p.name AS person_name
FROM
person p
LEFT JOIN workout w ON p.person_id = w.person_id AND w.start_date BETWEEN %s AND %s
LEFT JOIN topset t ON w.workout_id = t.workout_id
LEFT JOIN exercise e ON t.exercise_id = e.exercise_id
WHERE
p.person_id = %s
ORDER BY
w.start_date,
t.topset_id;
WITH workout_stats AS (
SELECT
w.workout_id,
w.start_date,
t.topset_id,
t.repetitions,
t.weight,
e.name AS exercise_name,
p.name AS person_name,
-- Max weight ever for this exercise before this set
MAX(t.weight) OVER (
PARTITION BY p.person_id, e.exercise_id
ORDER BY w.start_date, t.topset_id
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
) as prev_max_weight,
-- Weight from the last time this exercise was performed
LAG(t.weight) OVER (
PARTITION BY p.person_id, e.exercise_id
ORDER BY w.start_date, t.topset_id
) as prev_session_weight,
-- Reps from the last time this exercise was performed
LAG(t.repetitions) OVER (
PARTITION BY p.person_id, e.exercise_id
ORDER BY w.start_date, t.topset_id
) as prev_session_reps
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
WHERE
p.person_id = %s
)
SELECT * FROM workout_stats
WHERE start_date BETWEEN %s AND %s
ORDER BY start_date, topset_id;
"""
return db_executor(query, [person_id, start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')])
else:
query = """
SELECT
@@ -69,8 +88,7 @@ def _fetch_workout_data(db_executor, person_id, start_date, end_date, include_de
ORDER BY
w.start_date;
"""
# Ensure dates are passed in a format the DB understands (e.g., YYYY-MM-DD strings)
return db_executor(query, [start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d'), person_id])
return db_executor(query, [start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d'), person_id])
def _group_workouts_by_date(workouts_data):
"""Groups workout data by date and workout ID."""
@@ -97,10 +115,21 @@ def _group_workouts_by_date(workouts_data):
# Add set details if topset_id exists
if row.get('topset_id'):
weight = row.get('weight') or 0
reps = row.get('repetitions') or 0
prev_max = row.get('prev_max_weight') or 0
prev_weight = row.get('prev_session_weight') or 0
prev_reps = row.get('prev_session_reps') or 0
is_pr = weight > prev_max and prev_max > 0
is_improvement = (weight > prev_weight) or (weight == prev_weight and reps > prev_reps) if prev_weight > 0 else False
workouts_by_date[workout_date][workout_id]['sets'].append({
'repetitions': row.get('repetitions'),
'weight': row.get('weight'),
'exercise_name': row.get('exercise_name')
'repetitions': reps,
'weight': weight,
'exercise_name': row.get('exercise_name'),
'is_pr': is_pr,
'is_improvement': is_improvement
})
# Convert nested defaultdict to regular dict
@@ -119,12 +148,38 @@ def _process_workouts_for_month_view(grouped_workouts, start_date, end_date, sel
day_workouts_dict = grouped_workouts.get(current_date, {})
day_workouts_list = list(day_workouts_dict.values()) # Convert workout dicts to list
total_sets = 0
has_pr = False
has_improvement = False
pr_count = 0
improvement_count = 0
unique_exercise_names = []
for workout in day_workouts_list:
total_sets += len(workout.get('sets', []))
for s in workout.get('sets', []):
if s.get('is_pr'):
has_pr = True
pr_count += 1
if s.get('is_improvement'):
has_improvement = True
improvement_count += 1
name = s.get('exercise_name')
if name and name not in unique_exercise_names:
unique_exercise_names.append(name)
days_data.append({
'date_obj': current_date, # Pass the date object for easier template logic
'day': current_date.day,
'is_today': current_date == today, # Correct comparison: date object == date object
'is_in_current_month': current_date.month == selected_date.month,
'has_workouts': len(day_workouts_list) > 0,
'workout_count': len(day_workouts_list),
'total_sets': total_sets,
'has_pr': has_pr,
'has_improvement': has_improvement,
'pr_count': pr_count,
'improvement_count': improvement_count,
'exercise_names': unique_exercise_names[:3], # Limit to first 3 for summary
'workouts': day_workouts_list
})
current_date += timedelta(days=1)
@@ -212,6 +267,25 @@ def get_calendar(person_id):
# Add view-specific data
if selected_view == 'month':
calendar_view_data['days'] = _process_workouts_for_month_view(grouped_workouts, start_date, end_date, selected_date)
# Calculate summary stats for the selected month
total_workouts = 0
total_sets = 0
unique_exercises = set()
for workout_date, workouts in grouped_workouts.items():
if workout_date.month == selected_date.month and workout_date.year == selected_date.year:
total_workouts += len(workouts)
for workout in workouts.values():
total_sets += len(workout.get('sets', []))
for topset in workout.get('sets', []):
if topset.get('exercise_name'):
unique_exercises.add(topset.get('exercise_name'))
calendar_view_data['summary_stats'] = {
'total_workouts': total_workouts,
'total_sets': total_sets,
'total_exercises': len(unique_exercises)
}
elif selected_view == 'year':
calendar_view_data['months'] = _process_workouts_for_year_view(grouped_workouts, selected_date)

184
routes/exercises.py Normal file
View File

@@ -0,0 +1,184 @@
from flask import Blueprint, render_template, request, url_for
from flask_login import login_required, current_user
from extensions import db
from decorators import admin_required
exercises_bp = Blueprint('exercises', __name__)
@exercises_bp.route("/exercise", methods=['POST'])
@login_required
def create_exercise():
name = request.form.get("name")
attribute_ids = request.form.getlist('attribute_ids')
exercise = db.create_exercise(name, attribute_ids)
db.activityRequest.log(current_user.id, 'CREATE_EXERCISE', 'exercise', exercise['exercise_id'], f"Created exercise: {name}")
return render_template('partials/exercise.html',
exercise_id=exercise['exercise_id'],
name=exercise['name'],
attributes=exercise['attributes'])
@exercises_bp.route("/exercise/<int:exercise_id>", methods=['GET'])
def get_exercise(exercise_id):
exercise = db.get_exercise(exercise_id)
return render_template('partials/exercise.html',
exercise_id=exercise_id,
name=exercise['name'],
attributes=exercise['attributes'])
@exercises_bp.route("/exercise/<int:exercise_id>/edit_form", methods=['GET'])
@login_required
def get_exercise_edit_form(exercise_id):
exercise = db.get_exercise(exercise_id)
all_attributes = db.exercises.get_attributes_by_category()
# Format options for custom_select
formatted_options = {}
ex_attr_ids = [a['attribute_id'] for a in exercise['attributes']]
for cat, attrs in all_attributes.items():
formatted_options[cat] = [
{
"id": a['attribute_id'],
"name": a['name'],
"selected": a['attribute_id'] in ex_attr_ids
} for a in attrs
]
return render_template('partials/exercise.html',
exercise_id=exercise_id,
name=exercise['name'],
attributes=exercise['attributes'],
all_attributes=formatted_options,
is_edit=True)
@exercises_bp.route("/exercise/<int:exercise_id>/update", methods=['PUT'])
@login_required
def update_exercise(exercise_id):
new_name = request.form.get('name')
attribute_ids = request.form.getlist('attribute_ids')
exercise = db.update_exercise(exercise_id, new_name, attribute_ids)
db.activityRequest.log(current_user.id, 'UPDATE_EXERCISE', 'exercise', exercise_id, f"Updated exercise: {new_name}")
return render_template('partials/exercise.html',
exercise_id=exercise_id,
name=exercise['name'],
attributes=exercise['attributes'])
@exercises_bp.route("/exercises/get")
def get_exercises():
query = request.args.get('query')
person_id = request.args.get('person_id', type=int)
exercises = db.exercises.get(query)
return render_template('partials/exercise/exercise_dropdown.html', exercises=exercises, person_id=person_id)
@exercises_bp.route("/exercise/<int:exercise_id>/edit_name", methods=['GET', 'POST'])
@login_required
def edit_exercise_name(exercise_id):
exercise = db.exercises.get_exercise(exercise_id)
person_id = request.args.get('person_id', type=int)
if request.method == 'GET':
return render_template('partials/exercise/edit_exercise_name.html', exercise=exercise, person_id=person_id)
else:
updated_name = request.form['name']
updated_exercise = db.exercises.update_exercise_name(exercise_id, updated_name)
return render_template('partials/exercise/exercise_list_item.html', exercise=updated_exercise, person_id=person_id)
@exercises_bp.route("/exercises/add", methods=['POST'])
@login_required
def add_exercise():
exercise_name = request.form['query']
new_exercise = db.exercises.add_exercise(exercise_name)
person_id = request.args.get('person_id', type=int)
return render_template('partials/exercise/exercise_list_item.html', exercise=new_exercise, person_id=person_id)
@exercises_bp.route("/exercises/search")
@login_required
def search_exercises():
query = request.args.get('q', '')
exercises = db.exercises.get(query)
html = ""
for exercise in exercises:
html += render_template('partials/exercise.html',
exercise_id=exercise['exercise_id'],
name=exercise['name'],
attributes=exercise['attributes'])
return html
@exercises_bp.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
@login_required
@admin_required
def delete_exercise(exercise_id):
exercise = db.get_exercise(exercise_id)
db.exercises.delete_exercise(exercise_id)
db.activityRequest.log(current_user.id, 'DELETE_EXERCISE', 'exercise', exercise_id, f"Deleted exercise: {exercise['name']}")
return ""
# Category Management Routes
@exercises_bp.route("/category", methods=['POST'])
@login_required
@admin_required
def create_category():
name = request.form.get("name")
category = db.exercises.add_category(name)
db.activityRequest.log(current_user.id, 'CREATE_CATEGORY', 'category', category['category_id'], f"Created attribute category: {name}")
return render_template('partials/exercise/category_admin.html', category_id=category['category_id'], name=category['name'], attributes=[])
@exercises_bp.route("/category/<int:category_id>", methods=['GET', 'PUT'])
@login_required
@admin_required
def update_category(category_id):
if request.method == 'GET':
category = db.exercises.execute('SELECT category_id, name FROM exercise_attribute_category WHERE category_id = %s', [category_id], one=True)
is_edit = request.args.get('is_edit') == 'true'
all_attrs = db.exercises.execute('SELECT attribute_id, name FROM exercise_attribute WHERE category_id = %s', [category_id])
return render_template('partials/exercise/category_admin.html', category_id=category_id, name=category['name'], attributes=all_attrs, is_edit=is_edit)
name = request.form.get("name")
category = db.exercises.update_category(category_id, name)
db.activityRequest.log(current_user.id, 'UPDATE_CATEGORY', 'category', category_id, f"Updated attribute category: {name}")
all_attrs = db.exercises.execute('SELECT attribute_id, name FROM exercise_attribute WHERE category_id = %s', [category_id])
return render_template('partials/exercise/category_admin.html', category_id=category_id, name=name, attributes=all_attrs)
@exercises_bp.route("/category/<int:category_id>", methods=['DELETE'])
@login_required
@admin_required
def delete_category(category_id):
db.exercises.delete_category(category_id)
db.activityRequest.log(current_user.id, 'DELETE_CATEGORY', 'category', category_id, f"Deleted attribute category")
return ""
# Attribute Management Routes
@exercises_bp.route("/attribute", methods=['POST'])
@login_required
@admin_required
def create_attribute():
name = request.form.get("name")
category_id = request.form.get("category_id", type=int)
attribute = db.exercises.add_attribute(name, category_id)
db.activityRequest.log(current_user.id, 'CREATE_ATTRIBUTE', 'attribute', attribute['attribute_id'], f"Created attribute: {name}")
return render_template('partials/exercise/attribute_admin.html', attribute=attribute)
@exercises_bp.route("/attribute/<int:attribute_id>", methods=['GET', 'PUT'])
@login_required
@admin_required
def update_attribute(attribute_id):
if request.method == 'GET':
attribute = db.exercises.execute('SELECT attribute_id, name, category_id FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], one=True)
is_edit = request.args.get('is_edit') == 'true'
return render_template('partials/exercise/attribute_admin.html', attribute=attribute, is_edit=is_edit)
name = request.form.get("name")
attribute = db.exercises.update_attribute(attribute_id, name)
db.activityRequest.log(current_user.id, 'UPDATE_ATTRIBUTE', 'attribute', attribute_id, f"Updated attribute: {name}")
return render_template('partials/exercise/attribute_admin.html', attribute=attribute)
@exercises_bp.route("/attribute/<int:attribute_id>", methods=['DELETE'])
@login_required
@admin_required
def delete_attribute(attribute_id):
db.exercises.delete_attribute(attribute_id)
db.activityRequest.log(current_user.id, 'DELETE_ATTRIBUTE', 'attribute', attribute_id, "Deleted attribute")
return ""

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, render_template, request, current_app
from jinja2_fragments import render_block
from flask_htmx import HTMX
from flask_login import current_user
from extensions import db # Still need db for execute method
from decorators import validate_person, validate_workout
@@ -91,6 +92,7 @@ def update_workout_note(person_id, workout_id):
"""Updates a specific workout note."""
note = request.form.get('note')
_update_workout_note_for_person(person_id, workout_id, note) # Use local helper
db.activityRequest.log(current_user.id, 'UPDATE_NOTE', 'workout', workout_id, f"Updated note for workout {workout_id}")
return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
@notes_bp.route("/person/<int:person_id>/workout/<int:workout_id>/note", methods=['GET'])

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)

68
routes/settings.py Normal file
View File

@@ -0,0 +1,68 @@
from flask import Blueprint, render_template, request
from flask_login import login_required
from jinja2_fragments import render_block
from extensions import db
from flask import current_app
settings_bp = Blueprint('settings', __name__)
@settings_bp.route("/settings")
@login_required
def settings():
# Detect HTMX via header since we don't have the global htmx object here
is_htmx = request.headers.get('HX-Request') == 'true'
if is_htmx:
return render_block(current_app.jinja_env, 'settings.html', 'content')
return render_template('settings.html')
@settings_bp.route("/settings/tab/people")
@login_required
def settings_people():
people = db.get_people()
return render_template('partials/settings/people.html', people=people)
@settings_bp.route("/settings/tab/exercises")
@login_required
def settings_exercises():
exercises = db.get_all_exercises()
all_attributes = db.exercises.get_attributes_by_category()
categories_list = db.exercises.get_all_attribute_categories()
# Format options for custom_select
formatted_options = {}
for cat, attrs in all_attributes.items():
formatted_options[cat] = [{"id": a['attribute_id'], "attribute_id": a['attribute_id'], "name": a['name'], "category_id": a['category_id']} for a in attrs]
return render_template('partials/settings/exercises.html',
exercises=exercises,
all_attributes=formatted_options,
categories_list=categories_list)
@settings_bp.route("/settings/tab/export")
@login_required
def settings_export():
return render_template('partials/settings/export.html')
@settings_bp.route("/settings/tab/activity")
@login_required
def settings_activity():
return render_template('partials/settings/activity.html')
@settings_bp.route("/settings/activity_logs")
@login_required
def settings_activity_logs():
limit = 50
offset = request.args.get('offset', 0, type=int)
search_query = request.args.get('search_query', '')
logs = db.activityRequest.get_recent_logs(limit=limit, offset=offset, search_query=search_query)
# Check if there are more logs to load
has_more = len(logs) == limit
return render_template('partials/activity_logs.html',
logs=logs,
offset=offset,
has_more=has_more,
search_query=search_query,
limit=limit)

View File

@@ -80,7 +80,7 @@ def _delete_saved_query(query_id):
def _generate_sql_from_natural_language(natural_query):
"""Generates SQL query from natural language using Gemini REST API."""
gemni_model = os.environ.get("GEMINI_MODEL","gemini-2.0-flash")
gemni_model = os.environ.get("GEMINI_MODEL","gemini-2.5-flash-lite")
api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
return None, "GEMINI_API_KEY environment variable not set."

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, redirect, url_for, request, current_app
from jinja2_fragments import render_block
from flask_htmx import HTMX
from flask_login import login_required
from flask_login import login_required, current_user
from extensions import db
from decorators import validate_workout, validate_topset, require_ownership, validate_person
from utils import convert_str_to_date
@@ -140,6 +140,7 @@ def _get_workout_view_model(person_id, workout_id):
@require_ownership
def create_workout(person_id):
new_workout_id = db.create_workout(person_id)
db.activityRequest.log(current_user.id, 'CREATE_WORKOUT', 'workout', new_workout_id, f"Created workout for person_id: {person_id}")
# Use the local helper function to get the view model
view_model = _get_workout_view_model(person_id, new_workout_id)
if "error" in view_model: # Handle case where workout creation might fail or is empty
@@ -153,6 +154,7 @@ def create_workout(person_id):
@require_ownership
def delete_workout(person_id, workout_id):
db.delete_workout(workout_id)
db.activityRequest.log(current_user.id, 'DELETE_WORKOUT', 'workout', workout_id, f"Deleted workout: {workout_id}")
return redirect(url_for('calendar.get_calendar', person_id=person_id))
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date_edit_form", methods=['GET'])
@@ -172,6 +174,7 @@ def get_workout_start_date_edit_form(person_id, workout_id):
def update_workout_start_date(person_id, workout_id):
new_start_date_str = request.form.get('start-date')
db.update_workout_start_date(workout_id, new_start_date_str)
db.activityRequest.log(current_user.id, 'UPDATE_WORKOUT_START_DATE', 'workout', workout_id, f"Updated start date to {new_start_date_str}")
# Convert string back to date for rendering the partial
new_start_date = convert_str_to_date(new_start_date_str, '%Y-%m-%d')
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=new_start_date)
@@ -213,7 +216,26 @@ def get_topset(person_id, workout_id, topset_id):
def get_topset_edit_form(person_id, workout_id, topset_id):
exercises = db.get_all_exercises()
topset = db.get_topset(topset_id)
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercises=exercises, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'), is_edit=True)
# Format exercises for custom_select
formatted_exercises = [
{
"exercise_id": ex['exercise_id'],
"name": ex['name'],
"selected": ex['exercise_id'] == topset.get('exercise_id')
} for ex in exercises
]
return render_template('partials/topset.html',
person_id=person_id,
workout_id=workout_id,
topset_id=topset_id,
exercises=formatted_exercises,
exercise_id=topset.get('exercise_id'),
exercise_name=topset.get('exercise_name'),
repetitions=topset.get('repetitions'),
weight=topset.get('weight'),
is_edit=True)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset", methods=['POST'])
@login_required
@@ -223,8 +245,19 @@ def create_topset(person_id, workout_id):
exercise_id = request.form.get("exercise_id")
repetitions = request.form.get("repetitions")
weight = request.form.get("weight")
# Validation: Ensure exercise_id is present and is a valid integer
if not exercise_id or not exercise_id.strip():
return "Please select an exercise.", 400
try:
exercise_id = int(exercise_id)
except ValueError:
return "Invalid exercise selection.", 400
new_topset_id = db.create_topset(workout_id, exercise_id, repetitions, weight)
exercise = db.get_exercise(exercise_id)
db.activityRequest.log(current_user.id, 'ADD_SET', 'topset', new_topset_id, f"Added set: {repetitions} x {weight}kg {exercise['name']} in workout {workout_id}")
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=new_topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight), 200, {"HX-Trigger": "topsetAdded"}
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['PUT'])
@@ -237,6 +270,7 @@ def update_topset(person_id, workout_id, topset_id):
weight = request.form.get("weight")
db.update_topset(exercise_id, repetitions, weight, topset_id)
exercise = db.get_exercise(exercise_id)
db.activityRequest.log(current_user.id, 'UPDATE_SET', 'topset', topset_id, f"Updated set {topset_id}: {repetitions} x {weight}kg {exercise['name']}")
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/delete", methods=['DELETE'])
@@ -244,7 +278,9 @@ def update_topset(person_id, workout_id, topset_id):
@validate_topset
@require_ownership
def delete_topset(person_id, workout_id, topset_id):
topset = db.get_topset(topset_id)
db.delete_topset(topset_id)
db.activityRequest.log(current_user.id, 'DELETE_SET', 'topset', topset_id, f"Deleted set {topset_id}: {topset['exercise_name']} in workout {workout_id}")
return ""
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])
@@ -260,6 +296,42 @@ def get_most_recent_topset_for_exercise(person_id, workout_id):
(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)
@workout_bp.route("/person/<int:person_id>/exercise/<int:exercise_id>/history", methods=['GET'])
def get_exercise_history(person_id, exercise_id):
limit = request.args.get('limit', type=int, default=5)
offset = request.args.get('offset', type=int, default=0)
source_topset_id = request.args.get('source_topset_id', type=int)
topsets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit, offset)
title = "History"
best_fit_formula = None
latest_workout_id = None
latest_topset_id = None
if topsets:
# Fetch progress data to get title and best_fit_formula if there's history
exercise_progress = db.get_exercise_progress_for_user(
person_id, exercise_id, epoch='all')
if exercise_progress:
title = exercise_progress.get('title', "History")
best_fit_formula = exercise_progress.get('best_fit_formula')
latest_workout_id = exercise_progress.get('latest_workout_id')
latest_topset_id = exercise_progress.get('latest_topset_id')
return render_template('partials/exercise_history.html',
person_id=person_id,
exercise_id=exercise_id,
topsets=topsets,
limit=limit,
offset=offset,
source_topset_id=source_topset_id,
title=title,
best_fit_formula=best_fit_formula,
latest_workout_id=latest_workout_id,
latest_topset_id=latest_topset_id)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>", methods=['GET'])
def show_workout(person_id, workout_id):
# Use the local helper function to get the view model

View File

@@ -1 +0,0 @@
python-3.9.18

View File

@@ -10,7 +10,7 @@ tr.htmx-swapping td {
bottom: 0px;
left: 0px;
right: 0px;
background-color: rgba(0,0,0,0.9);
background-color: rgba(0, 0, 0, 0.9);
z-index: 1000;
/* Flexbox centers the .modal-content vertically and horizontally */
display: flex;
@@ -22,42 +22,42 @@ tr.htmx-swapping td {
animation-timing-function: ease;
}
#modal > .modal-underlay {
/* underlay takes up the entire viewport. This is only
#modal>.modal-underlay {
/* underlay takes up the entire viewport. This is only
required if you want to click to dismiss the popup */
position: absolute;
z-index: -1;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
}
position: absolute;
z-index: -1;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
}
#modal > .modal-content {
/* Display properties for visible dialog*/
border: solid 1px #999;
border-radius: 8px;
box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.3);
background-color: white;
/* Animate when opening */
animation-name: zoomIn;
animation-duration: 150ms;
animation-timing-function: ease;
}
#modal>.modal-content {
/* Display properties for visible dialog*/
border: solid 1px #999;
border-radius: 8px;
box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.3);
background-color: white;
/* Animate when opening */
animation-name: zoomIn;
animation-duration: 150ms;
animation-timing-function: ease;
}
#modal.closing {
/* Animate when closing */
animation-name: fadeOut;
animation-duration: 150ms;
animation-timing-function: ease;
}
#modal.closing {
/* Animate when closing */
animation-name: fadeOut;
animation-duration: 150ms;
animation-timing-function: ease;
}
#modal.closing > .modal-content {
/* Aniate when closing */
animation-name: zoomOut;
animation-duration: 150ms;
animation-timing-function: ease;
}
#modal.closing>.modal-content {
/* Aniate when closing */
animation-name: zoomOut;
animation-duration: 150ms;
animation-timing-function: ease;
}
@keyframes fadeIn {
0% {
@@ -99,20 +99,29 @@ tr.htmx-swapping td {
}
}
.loading-indicator{
display:none;
.loading-indicator {
display: none;
}
.htmx-request .loading-indicator{
display:flex;
.htmx-request .loading-indicator {
display: flex;
}
.htmx-request.loading-indicator{
display:flex;
.htmx-request.loading-indicator {
display: flex;
}
@keyframes slideInLeft {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slideInLeft {
@@ -122,12 +131,76 @@ tr.htmx-swapping td {
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fadeIn {
animation-name: fadeIn;
animation-duration: 0.5s;
animation-fill-mode: both;
}
/* SQL Explorer Custom Styles */
.sql-editor-container {
background: #1e1e1e;
border-radius: 0.5rem;
padding: 0.5rem;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
}
.sql-editor-textarea {
background: transparent !important;
color: #dcdcdc !important;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
line-height: 1.5;
tab-size: 4;
border: none !important;
outline: none !important;
width: 100%;
}
.glass-card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.btn-premium {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.btn-premium::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent);
transition: 0.5s;
}
.btn-premium:hover::after {
left: 100%;
}
.table-zebra tbody tr:nth-child(even) {
background-color: rgba(243, 244, 246, 0.5);
}
.table-zebra tbody tr:hover {
background-color: rgba(229, 231, 235, 0.8);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 272 KiB

2029
static/js/mermaid.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,15 +7,16 @@
<title>Workout Tracker</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/logo.png') }}">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900&display=swap" rel="stylesheet" />
<link rel="stylesheet" type="text/css"
href="https://cdn.rawgit.com/dreampulse/computer-modern-web-font/master/fonts.css">
<!-- Resource Preloads -->
<link rel="preload" href="/static/css/tailwind.css" as="style">
<link rel="preload" href="/static/js/htmx.min.js" as="script">
<link href="/static/css/tailwind.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet">
<script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/hyperscript.min.js"></script>
<script src="/static/js/sweetalert2@11.js" defer></script>
<script src="/static/js/hyperscript.min.js" defer></script>
</head>
<body>
@@ -191,8 +192,8 @@
</svg>
<span class="ml-3">Endpoints</span>
</a>
<a hx-get="{{ url_for('settings') }}" 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('settings')) }} page-link"
<a hx-get="{{ url_for('settings.settings') }}" 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('settings.settings')) }} 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 class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"

View File

@@ -39,7 +39,32 @@
</span>
</div>
<div class="mr-4">
<div class="flex items-center space-x-2 mr-4">
{% if view == 'month' %}
<div
class="hidden lg:flex items-center space-x-3 text-xs font-medium text-gray-500 border-l border-gray-200 pl-4 h-6">
<div class="flex items-center">
<span class="text-blue-600 mr-1">{{ summary_stats.total_workouts }}</span>
<span class="uppercase tracking-wider">Workouts</span>
</div>
<div class="flex items-center">
<span class="text-blue-600 mr-1">{{ summary_stats.total_sets }}</span>
<span class="uppercase tracking-wider">Sets</span>
</div>
{% if summary_stats.total_workouts > 0 %}
<div class="flex items-center">
<span class="text-blue-600 mr-1">{{ (summary_stats.total_sets / summary_stats.total_workouts) |
round(1) }}</span>
<span class="uppercase tracking-wider">Sets/Session</span>
</div>
{% endif %}
<div class="flex items-center">
<span class="text-blue-600 mr-1">{{ summary_stats.total_exercises }}</span>
<span class="uppercase tracking-wider">Exercises</span>
</div>
</div>
{% endif %}
{{ render_partial('partials/custom_select.html',
name='view',
options=[
@@ -60,62 +85,103 @@
</div>
{% if view == 'month' %}
<div class="flex flex-col px-2 py-2 -mb-px">
<div class="grid grid-cols-7 pl-2 pr-2">
<div class="flex flex-col px-1 sm:px-2 py-2 -mb-px">
<div class="grid grid-cols-7">
<div class="p-2 h-10 text-center font-bold">
<div class="p-1 h-8 text-center font-bold text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Sunday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sun</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">S</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<div class="p-1 h-8 text-center font-bold text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Monday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Mon</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">M</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<div class="p-1 h-8 text-center font-bold text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Tuesday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Tue</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">T</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<div class="p-1 h-8 text-center font-bold text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Wednesday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Wed</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">W</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<div class="p-1 h-8 text-center font-bold text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Thursday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Thu</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">T</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<div class="p-1 h-8 text-center font-bold text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Friday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Fri</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">F</span>
</div>
<div class="p-2 h-10 text-center font-bold">
<div class="p-1 h-8 text-center font-bold text-xs">
<span class="xl:block lg:block md:block sm:block hidden">Saturday</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sat</span>
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">S</span>
</div>
</div>
<div class="grid grid-cols-7 overflow-hidden flex-1 pl-2 pr-2 w-full">
<div class="grid grid-cols-7 overflow-hidden flex-1 w-full border-t border-l">
{% for day in days %}
<div
class="{% if day.is_today %}rounded-md border-4 border-green-50{% endif %} border flex flex-col h-36 sm:h-40 md:h-30 lg:h-30 mx-auto mx-auto overflow-hidden w-full pt-2 pl-1 cursor-pointer {% if day.is_in_current_month %}bg-gray-100{% endif %}">
<div class="top h-5 w-full">
<span class="text-gray-500 font-semibold">{{ day.day }}</span>
class="{% if day.is_today %}ring-2 ring-green-100 ring-inset{% endif %} border-b border-r flex flex-col h-20 sm:h-40 md:h-30 lg:h-30 mx-auto overflow-hidden w-full pt-1 px-1 cursor-pointer relative {% if not day.is_in_current_month %}opacity-40{% else %}bg-gray-50/50{% endif %}">
<div class="flex justify-between items-start mb-0.5">
<span class="text-gray-400 font-medium text-[9px] sm:text-xs leading-none">{{ day.day }}</span>
{% if day.has_workouts and (day.pr_count > 0 or day.improvement_count > 0) %}
<div
class="flex items-center bg-white/80 border border-gray-100 rounded-full px-1 shadow-sm h-3.5 sm:h-4">
{% if day.pr_count > 0 %}
<span class="text-[8px] sm:text-[9px] font-bold text-yellow-600 flex items-center">
🏆<span class="ml-0.5">{{ day.pr_count }}</span>
</span>
{% endif %}
{% if day.pr_count > 0 and day.improvement_count > 0 %}
<span class="mx-0.5 text-gray-300 text-[8px]">|</span>
{% endif %}
{% if day.improvement_count > 0 %}
<span class="text-[8px] sm:text-[9px] font-bold text-green-600 flex items-center">
<span class="ml-0.5">{{ day.improvement_count }}</span>
</span>
{% endif %}
</div>
{% endif %}
</div>
{% for workout in day.workouts %}
<div class="bottom flex-grow py-1 w-full"
hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.workout_id) }}"
{% 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"
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 set in workout.sets %}
<button
class="flex flex-col xl:flex-row items-start lg:items-center flex-shrink-0 px-0 sm:px-0.5 md:px-0.5 lg:px-0.5 text-xs">
<span class="ml-0 sm:ml-0.5 md:ml-2 lg:ml-2 font-medium leading-none truncate">{{
set.exercise_name }}</span>
<span class="ml-0 sm:ml-0.5 md:ml-2 lg:ml-2 font-light leading-none">{{ set.repetitions }} x {{
set.weight }}kg</span>
</button>
{% for name in day.exercise_names %}
<div class="truncate pl-0.5 border-l border-blue-200">{{ name }}</div>
{% endfor %}
</div>
{% endfor %}
<!-- Desktop Detailed List -->
<div class="hidden sm:block flex-1 overflow-hidden">
{% for workout in day.workouts %}
<div class="py-1 w-full"
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 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-[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>
{% elif set.is_improvement %}
<span class="ml-1 text-green-500 font-bold shrink-0 text-[8px]"></span>
{% endif %}
</span>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}

View File

@@ -10,6 +10,40 @@
<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 entries for January 2026 -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">January 31, 2026</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Introduced a comprehensive system for logging user and administrative actions, accessible via a new
"Activity Logs" tab in settings.</li>
<li>Implemented role-based access control, restricting exercise and user management to administrators.
</li>
<li>Overhauled the Settings page with a responsive, tabbed interface using Hyperscript.</li>
</ul>
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">January 30, 2026</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Added "Machine vs Free Weight" and "Compound vs Isolation" breakdowns to provide deeper insights
into workout composition.</li>
<li>Improved the visual design and responsiveness of achievement badges for topsets.</li>
</ul>
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">January 29, 2026</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Enforced authentication for executing and managing SQL queries in the SQL Explorer.</li>
<li>Refactored dashboard distribution graphs using Polars to significantly improve performance and load
times.</li>
</ul>
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">January 28, 2026</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Fixed several layout issues on mobile devices, including navbar overlapping and header spacing.</li>
<li>Refactored the multi-select component for better stability and HTMX integration.</li>
</ul>
<!-- New Entry for Workout Programs -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 24, 2025</h2>

View File

@@ -130,8 +130,10 @@
<div class="overflow-x-auto rounded-lg">
<div class="align-middle inline-block min-w-full">
<div class="shadow overflow-hidden sm:rounded-lg">
<div class="w-full mt-2 pb-2 aspect-video">
{{ render_partial('partials/sparkline.html', **exercise.graph) }}
<div class="w-full mt-2 pb-2 aspect-video"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person.id, exercise_id=exercise.id, min_date=min_date, max_date=max_date) }}"
hx-trigger="intersect once" hx-swap="outerHTML">
{{ render_partial('partials/skeleton_graph.html') }}
</div>
<table class="min-w-full divide-y divide-gray-200">

View File

@@ -0,0 +1,74 @@
{% if offset == 0 %}
<div class="overflow-x-auto rounded-lg">
<div class="align-middle inline-block min-w-full">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details
</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP & Source
</th>
</tr>
</thead>
<tbody id="activity-logs-tbody" class="bg-white divide-y divide-gray-200">
{% endif %}
{% for log in logs %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-4 whitespace-nowrap text-sm text-gray-500">{{ log.timestamp.strftime('%Y-%m-%d
%H:%M:%S') }}</td>
<td class="p-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ log.person_name or
'System' }}</td>
<td class="p-4 whitespace-nowrap text-sm text-gray-500">
<span class="px-2 py-1 text-xs font-semibold rounded-full
{% if 'DELETE' in log.action %}bg-red-100 text-red-800
{% elif 'CREATE' in log.action or 'ADD' in log.action %}bg-green-100 text-green-800
{% elif 'UPDATE' in log.action %}bg-blue-100 text-blue-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ log.action }}
</span>
</td>
<td class="p-4 text-sm text-gray-600">{{ log.details }}</td>
<td class="p-4 whitespace-nowrap text-sm text-gray-400">
<div class="font-mono text-gray-500">{{ log.ip_address }}</div>
<div class="text-xs truncate max-w-[150px] text-gray-400" title="{{ log.user_agent }}">
{{ log.user_agent or 'Unknown Source' }}
</div>
</td>
</tr>
{% endfor %}
{% if has_more %}
<tr id="load-more-row">
<td colspan="5" class="p-4 text-center">
<button
hx-get="{{ url_for('settings.settings_activity_logs', offset=offset + limit, search_query=search_query) }}"
hx-target="#load-more-row" hx-swap="outerHTML"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-cyan-700 bg-cyan-100 hover:bg-cyan-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors">
Load More...
</button>
</td>
</tr>
{% endif %}
{% if offset == 0 %}
{% if not logs %}
<tr>
<td colspan="5" class="p-8 text-center text-gray-500 italic">No activity logs found.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}

View File

@@ -1,19 +1,30 @@
<tr>
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 w-1/5">
<tr class="hover:bg-gray-50/50 transition-colors group">
<td class="p-4 text-sm font-semibold text-gray-900">
{% if is_edit|default(false, true) == false %}
{{ name }}
<div class="flex flex-col">
<span class="text-base font-bold text-gray-900">{{ name }}</span>
<!-- Mobile-only attributes -->
<div class="flex flex-wrap gap-1 mt-1 sm:hidden">
{% for attr in attributes %}
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
{{ attr.attribute_name }}
</span>
{% endfor %}
</div>
</div>
{% else %}
<input
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
class="w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-2 px-3 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 transition-all font-normal"
type="text" name="name" value="{{ name }}">
{% endif %}
</td>
<td class="p-4 text-sm text-gray-900 w-3/5">
<td class="p-4 text-sm text-gray-900 hidden sm:table-cell">
{% if is_edit|default(false, true) == false %}
<div class="flex flex-wrap gap-2">
{% for attr in attributes %}
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-50 text-cyan-700 border border-cyan-100"
title="{{ attr.category_name }}">
{{ attr.attribute_name }}
</span>
@@ -22,8 +33,8 @@
{% else %}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{% for cat_name, options in all_attributes.items() %}
<div>
<label class="block text-xs font-semibold text-gray-500 uppercase mb-1">{{ cat_name }}</label>
<div class="min-w-[150px]">
<label class="block text-[10px] font-bold text-gray-400 uppercase mb-1">{{ cat_name }}</label>
{{ render_partial('partials/custom_select.html',
name='attribute_ids',
options=options,
@@ -36,52 +47,53 @@
</div>
{% endif %}
</td>
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 w-1/5 float-right">
{% if is_edit|default(false, true) == false %}
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-get="{{ url_for('get_exercise_edit_form', exercise_id=exercise_id) }}">
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
<span class="sr-only">Edit</span>
</button>
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-delete="{{ url_for('delete_exercise', exercise_id=exercise_id) }}"
hx-confirm="Are you sure you wish to delete {{ name }} from exercises?">
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span class="sr-only">Delete</span>
</button>
{% else %}
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-put="{{ url_for('update_exercise', exercise_id=exercise_id) }}" hx-include="closest tr">
<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="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="sr-only">Save</span>
</button>
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-get="{{ url_for('get_exercise', exercise_id=exercise_id) }}">
<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="M6 18L18 6M6 6l12 12" />
</svg>
<span class="sr-only">Cancel</span>
</button>
{% endif %}
<td class="p-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1">
{% if is_edit|default(false, true) == false %}
<button
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-cyan-50 hover:text-cyan-600 transition-all"
hx-get="{{ url_for('exercises.get_exercise_edit_form', exercise_id=exercise_id) }}"
title="Edit Exercise">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
<span class="sr-only">Edit</span>
</button>
<button
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-red-50 hover:text-red-500 transition-all"
hx-delete="{{ url_for('exercises.delete_exercise', exercise_id=exercise_id) }}"
hx-confirm="Are you sure you wish to delete {{ name }} from exercises?" title="Delete Exercise">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span class="sr-only">Delete</span>
</button>
{% else %}
<button
class="inline-flex justify-center p-2 text-white bg-cyan-600 rounded-lg cursor-pointer hover:bg-cyan-700 transition-all shadow-sm"
hx-put="{{ url_for('exercises.update_exercise', exercise_id=exercise_id) }}" hx-include="closest tr"
title="Save Changes">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="sr-only">Save</span>
</button>
<button
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-gray-100 transition-all"
hx-get="{{ url_for('exercises.get_exercise', exercise_id=exercise_id) }}" title="Cancel">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="sr-only">Cancel</span>
</button>
{% endif %}
</div>
</td>
</tr>

View File

@@ -0,0 +1,45 @@
<div id="attribute-{{ attribute.attribute_id }}"
class="group flex items-center px-3 py-1 bg-white border border-gray-200 rounded-lg shadow-sm hover:border-cyan-300 transition-all">
{% if is_edit %}
<form hx-put="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}"
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML" class="flex items-center space-x-2">
<input type="text" name="name" value="{{ attribute.name }}"
class="text-xs font-semibold text-gray-700 bg-gray-50 border-none p-0 focus:ring-0 w-20" autofocus
onfocus="this.select()">
<button type="submit" class="text-green-600 hover:text-green-700">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</button>
<button type="button" hx-get="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}"
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML"
class="text-gray-400 hover:text-gray-600">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</form>
{% else %}
<span class="text-xs font-medium text-gray-700 mr-2">{{ attribute.name }}</span>
<div class="hidden group-hover:flex items-center space-x-1 ml-auto">
<button hx-get="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}?is_edit=true"
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML"
class="p-1 text-gray-400 hover:text-cyan-600 transition-colors">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
</path>
</svg>
</button>
<button hx-delete="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}"
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML" hx-confirm="Delete this attribute?"
class="p-1 text-gray-400 hover:text-red-600 transition-colors">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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">
</path>
</svg>
</button>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,76 @@
<div id="category-{{ category_id }}"
class="bg-gray-50 border border-gray-200 rounded-2xl p-6 transition-all hover:shadow-md">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-4">
<div class="p-2 bg-cyan-100 rounded-lg text-cyan-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 11h.01M7 15h.01M11 7h.01M11 11h.01M11 15h.01M15 7h.01M15 11h.01M15 15h.01M19 7h.01M19 11h.01M19 15h.01M7 3h10a2 2 0 012 2v14a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z">
</path>
</svg>
</div>
{% if is_edit %}
<form hx-put="{{ url_for('exercises.update_category', category_id=category_id) }}"
hx-target="#category-{{ category_id }}" hx-swap="outerHTML" class="flex items-center space-x-2">
<input type="text" name="name" value="{{ name }}"
class="text-lg font-bold text-gray-900 bg-white border border-cyan-200 rounded-lg px-2 py-1 focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 outline-none"
autofocus onfocus="this.select()">
<button type="submit" class="text-green-600 hover:text-green-700 font-bold p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</button>
<button type="button" hx-get="{{ url_for('exercises.update_category', category_id=category_id) }}"
hx-target="#category-{{ category_id }}" hx-swap="outerHTML"
class="text-gray-400 hover:text-gray-600 p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
</button>
</form>
{% else %}
<h5 class="text-lg font-bold text-gray-900">{{ name }}</h5>
<button hx-get="{{ url_for('exercises.update_category', category_id=category_id) }}?is_edit=true"
hx-target="#category-{{ category_id }}" hx-swap="outerHTML"
class="text-gray-400 hover:text-cyan-600 p-1 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
</path>
</svg>
</button>
{% endif %}
</div>
<button hx-delete="{{ url_for('exercises.update_category', category_id=category_id) }}"
hx-target="#category-{{ category_id }}" hx-swap="outerHTML"
hx-confirm="Deleteting '{{ name }}' will also delete all its attributes. Are you sure?"
class="text-gray-400 hover:text-red-600 p-2 rounded-lg hover:bg-red-50 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
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">
</path>
</svg>
</button>
</div>
<div class="flex flex-wrap gap-2 mb-6" id="attributes-list-{{ category_id }}">
{% for attr in attributes %}
{{ render_partial('partials/exercise/attribute_admin.html', attribute=attr) }}
{% endfor %}
</div>
<form hx-post="{{ url_for('exercises.create_attribute') }}" hx-target="#attributes-list-{{ category_id }}"
hx-swap="beforeend" _="on htmx:afterRequest reset() me" class="flex items-center space-x-2">
<input type="hidden" name="category_id" value="{{ category_id }}">
<input type="text" name="name" placeholder="Add new option..."
class="flex-1 text-sm bg-white border border-gray-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 outline-none transition-all">
<button type="submit"
class="bg-white border border-gray-200 text-gray-600 hover:text-cyan-600 hover:border-cyan-200 p-2 rounded-xl transition-all shadow-sm active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
</button>
</form>
</div>

View File

@@ -4,7 +4,8 @@
class="w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-2 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
_="on click from me call event.stopPropagation()">
<!-- Save Icon -->
<button hx-post="{{ url_for('edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
<button
hx-post="{{ url_for('exercises.edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
hx-target="closest li" hx-swap="outerHTML" hx-include="closest li"
class="text-gray-500 hover:text-gray-700 ml-2" _="on click from me call event.stopPropagation()">
<!-- Tick icon SVG -->
@@ -14,7 +15,8 @@
</svg>
</button>
<!-- Delete Icon -->
<button hx-delete="{{ url_for('delete_exercise', exercise_id=exercise.exercise_id, person_id=person_id) }}"
<button
hx-delete="{{ url_for('exercises.delete_exercise', exercise_id=exercise.exercise_id, person_id=person_id) }}"
hx-target="closest li" hx-swap="outerHTML" class="text-red-500 hover:text-red-700 ml-2"
hx-confirm="Are you sure you wish to delete {{ exercise.name }} from exercises?"
_="on click from me call event.stopPropagation()">

View File

@@ -8,8 +8,8 @@
<div class="py-2 px-4 text-gray-500 flex items-center justify-between border border-gray-200">
<span>No results found</span>
<!-- Add Exercise Button -->
<button hx-post="{{ url_for('add_exercise', person_id=person_id) }}" hx-target="closest div" hx-swap="outerHTML"
hx-include="[name='query']" class="text-blue-500 hover:text-blue-700 font-semibold"
<button hx-post="{{ url_for('exercises.add_exercise', person_id=person_id) }}" hx-target="closest div"
hx-swap="outerHTML" hx-include="[name='query']" class="text-blue-500 hover:text-blue-700 font-semibold"
_="on click from me call event.stopPropagation()">
Add Exercise
</button>

View File

@@ -5,7 +5,7 @@
<!-- Exercise Name -->
<span>{{ exercise.name }}</span>
<!-- Edit Icon -->
<a hx-get="{{ url_for('edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
<a hx-get="{{ url_for('exercises.edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
hx-target="closest li" hx-swap="outerHTML" class="text-gray-500 hover:text-gray-700"
_="on click from me call event.stopPropagation()">
<!-- Edit icon SVG -->

View File

@@ -2,7 +2,7 @@
<input
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"
id="exercise-search" type="search" name="query" placeholder="Search exercises..."
hx-get="{{ url_for('get_exercises', person_id=person_id) }}" hx-target="#exercise-results"
hx-get="{{ url_for('exercises.get_exercises', person_id=person_id) }}" hx-target="#exercise-results"
hx-trigger="keyup changed delay:500ms" hx-swap="innerHTML" autocomplete="off" {% if exercise_name %}
value="{{ exercise_name }}" {% endif %} _="
on input

View File

@@ -0,0 +1,84 @@
{% if offset == 0 %}
<div id="exercise-history-container"
class="w-full bg-gray-50 p-4 border-t border-gray-200 shadow-inner overflow-x-auto">
<div class="flex items-center justify-between sm:justify-center relative mb-1">
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2">
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4>
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center">
</div>
</div>
<div class="absolute left-0 z-10">
<button
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100"
title="Show Progress Graph"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-target="#exercise-history-container" hx-swap="outerHTML">
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" 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>
</div>
{% if best_fit_formula %}
<h2 class="text-xs font-semibold text-blue-200 mb-4 text-center">
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
</h2>
{% else %}
<div class="mb-4"></div>
{% endif %}
<table class="w-full text-left text-sm text-gray-500">
<thead class="text-xs text-gray-700 uppercase bg-gray-100">
<tr>
<th scope="col" class="px-3 py-2">Date</th>
<th scope="col" class="px-3 py-2">Set & Achievements</th>
</tr>
</thead>
<tbody>
{% endif %}
{% for topset in topsets %}
<tr class="border-b bg-white">
<td class="px-3 py-2 text-sm text-gray-900 whitespace-nowrap">
{{ topset.start_date | strftime }}
</td>
<td class="px-3 py-2 text-sm text-gray-900">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="whitespace-nowrap">{{ topset.repetitions }} x {{ topset.weight }}kg</span>
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=topset.workout_id, topset_id=topset.topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML"
class="flex flex-wrap items-center gap-1">
<!-- Badges load here -->
</div>
</div>
</td>
</tr>
{% endfor %}
{% if topsets|length == limit %}
<tr id="history-load-more-{{ source_topset_id }}-{{ offset + limit }}">
<td colspan="2" class="px-3 py-3 text-center">
<button
class="text-sm text-blue-600 hover:underline font-medium px-4 py-2 border border-blue-600 rounded"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, limit=limit, offset=offset + limit, source_topset_id=source_topset_id) }}"
hx-target="#history-load-more-{{ source_topset_id }}-{{ offset + limit }}" hx-swap="outerHTML">
Load More
</button>
</td>
</tr>
{% elif topsets|length == 0 and offset == 0 %}
<tr>
<td colspan="2" class="px-3 py-4 text-center text-gray-500">
No history found.
</td>
</tr>
{% endif %}
{% if offset == 0 %}
</tbody>
</table>
</div>
{% endif %}

View File

@@ -1,68 +1,82 @@
<form class="w-full" id="new-set-workout-{{ workout_id }}"
hx-post="{{ url_for('workout.create_topset', person_id=person_id, workout_id=workout_id) }}" hx-swap="beforeend"
hx-target="#new-workout" _="on htmx:afterOnLoad if #no-workouts add .hidden to #no-workouts end
on topsetAdded
render #notification-template with (message: 'Topset added') then append it to #notifications-container
then call _hyperscript.processNode(#notifications-container)
then reset() me
then trigger clearNewSetInputs">
<div id="new-set-form-container-{{ workout_id }}" class="w-full">
<form class="w-full" id="new-set-workout-{{ workout_id }}"
hx-post="{{ url_for('workout.create_topset', person_id=person_id, workout_id=workout_id) }}" hx-swap="beforeend"
hx-target="#new-workout" _="on htmx:afterOnLoad
if #no-workouts add .hidden to #no-workouts end
if detail.xhr.status == 200
set #validation-error-{{ workout_id }}.innerText to ''
add .hidden to #validation-error-{{ workout_id }}
else
set #validation-error-{{ workout_id }}.innerText to detail.xhr.responseText
remove .hidden from #validation-error-{{ workout_id }}
end
on topsetAdded
render #notification-template with (message: 'Topset added') then append it to #notifications-container
then call _hyperscript.processNode(#notifications-container)
then reset() me
then trigger clearNewSetInputs">
<div id="validation-error-{{ workout_id }}"
class="hidden text-red-500 text-xs italic mb-4 p-2 bg-red-50 border border-red-200 rounded"></div>
<div class="flex flex-wrap -mx-3 mb-2">
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-state">
Exercise
</label>
{{ render_partial('partials/exercise/exercise_select.html', person_id=person_id,
exercise_id=exercise_id, exercise_name=exercise_name) }}
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
Reps
</label>
<input
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"
id="grid-city" type="number" name="repetitions" {% if repetitions %} placeholder="{{ repetitions }}"
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Weight
</label>
<input
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"
id="grid-zip" type="number" name="weight" step="any" {% if weight %} placeholder="{{ weight }}"
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[10%] px-2 md:px-3 mb-6 md:mb-0">
<button
class="flex items-center justify-center py-2 px-2 md:px-3 mb-3 text-sm font-medium text-center text-gray-900 bg-cyan-600 hover:bg-cyan-700 rounded-lg border border-gray-300 hover:scale-[1.02] transition-transform mb-6 md:mb-0 mt-0 md:mt-6 w-full"
type="submit">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-7 h-7">
<path
d="M12 4a1 1 0 011 1v6h6a1 1 0 110 2h-6v6a1 1 0 11-2 0v-6H5a1 1 0 110-2h6V5a1 1 0 011-1z" />
</svg>
</button>
</div>
<div class="flex flex-wrap -mx-3 mb-2">
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-state">
Exercise
</label>
{{ render_partial('partials/exercise/exercise_select.html', person_id=person_id,
exercise_id=exercise_id, exercise_name=exercise_name) }}
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
Reps
</label>
<input
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"
id="grid-city" type="number" name="repetitions" {% if repetitions %} placeholder="{{ repetitions }}"
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Weight
</label>
<input
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"
id="grid-zip" type="number" name="weight" step="any" {% if weight %} placeholder="{{ weight }}"
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[10%] px-2 md:px-3 mb-6 md:mb-0">
<button
class="flex items-center justify-center py-2 px-2 md:px-3 mb-3 text-sm font-medium text-center text-gray-900 bg-cyan-600 hover:bg-cyan-700 rounded-lg border border-gray-300 hover:scale-[1.02] transition-transform mb-6 md:mb-0 mt-0 md:mt-6 w-full"
type="submit">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-7 h-7">
<path d="M12 4a1 1 0 011 1v6h6a1 1 0 110 2h-6v6a1 1 0 11-2 0v-6H5a1 1 0 110-2h6V5a1 1 0 011-1z" />
</svg>
</button>
</div>
</form>
<div hx-trigger="exerciseSelected from:body"
hx-get="{{ url_for('workout.get_most_recent_topset_for_exercise', person_id=person_id, workout_id=workout_id) }}"
hx-target="#new-set-form-container-{{ workout_id }}" hx-include="[name='exercise_id']">
</div>
</form>
<div hx-trigger="exerciseSelected from:body"
hx-get="{{ url_for('workout.get_most_recent_topset_for_exercise', person_id=person_id, workout_id=workout_id) }}"
hx-target="#new-set-workout-{{ workout_id }}" hx-include="[name='exercise_id']">
</div>
{% if exercise_id %}
<div class="flex items-center justify-center">
<div class="md:w-full max-w-screen-sm">
<div class="hidden"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-trigger="load" hx-target="this" hx-swap="outerHTML">
{% if exercise_id %}
<div class="flex items-center justify-center">
<div class="w-full">
<div class="hidden"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-trigger="load" hx-target="this" hx-swap="outerHTML">
</div>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>

View File

@@ -8,52 +8,52 @@
type="text" name="name" value="{{ name }}">
{% endif %}
</td>
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 float-right">
{% if is_edit|default(false, true) == false %}
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-get="{{ url_for('get_person_edit_form', person_id=person_id) }}">
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
<span class="sr-only">Edit</span>
</button>
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-delete="{{ url_for('delete_person', person_id=person_id) }}"
hx-confirm="Are you sure you wish to delete {{ name }} from users?">
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span class="sr-only">Delete</span>
</button>
{% else %}
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-put="{{ url_for('update_person_name', person_id=person_id) }}" hx-include="closest tr">
<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="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="sr-only">Cancel</span>
</button>
<button
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
hx-get="{{ url_for('get_person_name', person_id=person_id) }}">
<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="M6 18L18 6M6 6l12 12" />
</svg>
<span class="sr-only">Cancel</span>
</button>
{% endif %}
<td class="p-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1">
{% if is_edit|default(false, true) == false %}
<button
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-cyan-50 hover:text-cyan-600 transition-all"
hx-get="{{ url_for('get_person_edit_form', person_id=person_id) }}" title="Edit User">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
<span class="sr-only">Edit</span>
</button>
<button
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-red-50 hover:text-red-500 transition-all"
hx-delete="{{ url_for('delete_person', person_id=person_id) }}"
hx-confirm="Are you sure you wish to delete {{ name }} from users?" title="Delete User">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
<span class="sr-only">Delete</span>
</button>
{% else %}
<button
class="inline-flex justify-center p-2 text-white bg-cyan-600 rounded-lg cursor-pointer hover:bg-cyan-700 transition-all shadow-sm"
hx-put="{{ url_for('update_person_name', person_id=person_id) }}" hx-include="closest tr"
title="Save Changes">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="sr-only">Save</span>
</button>
<button
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-gray-100 transition-all"
hx-get="{{ url_for('get_person_name', person_id=person_id) }}" title="Cancel">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="sr-only">Cancel</span>
</button>
{% endif %}
</div>
</td>
</tr>

View File

@@ -0,0 +1,31 @@
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-6 border-b border-gray-100 pb-4 flex justify-between items-center">
<div>
<h3 class="text-xl font-bold text-gray-900">Activity Logs</h3>
<p class="text-sm text-gray-500">Review recent actions and administrative changes.</p>
</div>
<div class="relative max-w-sm w-full">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd" />
</svg>
</div>
<input type="text" name="search_query"
class="focus:ring-cyan-500 focus:border-cyan-500 block w-full pl-10 p-2 sm:text-sm border-gray-300 rounded-lg bg-gray-50"
placeholder="Search logs by action, user, or details..."
hx-get="{{ url_for('settings.settings_activity_logs') }}" hx-trigger="keyup changed delay:500ms, search"
hx-target="#activity-logs-container">
</div>
</div>
<div id="activity-logs-container" hx-get="{{ url_for('settings.settings_activity_logs') }}" hx-trigger="load">
<div class="flex justify-center p-12">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
<p class="text-sm text-gray-500">Loading activity history...</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,163 @@
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-6">
<h3 class="text-xl font-bold text-gray-900">Exercise Configuration</h3>
<p class="text-sm text-gray-500">Manage available exercises and their categories.</p>
</div>
<div class="flex flex-col">
<div class="overflow-x-auto rounded-lg">
<div class="align-middle inline-block min-w-full">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="p-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
Exercise Name
</th>
<th scope="col"
class="p-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">
Attributes
</th>
<th scope="col"
class="p-4 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">
<div class="relative max-w-xs ml-auto">
<div
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-400" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
</svg>
</div>
<input type="search" id="exercise-search" name="q"
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 transition-all shadow-sm"
placeholder="Search e.g. 'muscle:chest'..." hx-get="/exercises/search"
hx-trigger="input changed delay:250ms, search" hx-target="#new-exercise"
hx-indicator="#search-spinner">
<div id="search-spinner"
class="htmx-indicator absolute inset-y-0 right-3 flex items-center">
<svg class="animate-spin h-4 w-4 text-cyan-600"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
</div>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100" id="new-exercise" hx-target="closest tr"
hx-swap="innerHTML swap:0.5s">
{% for exercise in exercises %}
{{ render_partial('partials/exercise.html', exercise_id=exercise.exercise_id,
name=exercise.name, attributes=exercise.attributes)}}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-12 bg-gray-50/50 p-6 rounded-2xl border border-gray-100 shadow-sm">
<div class="mb-6">
<h4 class="text-lg font-bold text-gray-900">Add New Exercise</h4>
<p class="text-sm text-gray-500">Create a new exercise with specific muscle groups and equipment.
</p>
</div>
<form hx-post="{{ url_for('exercises.create_exercise') }}" hx-swap="beforeend" hx-target="#new-exercise" _="on htmx:afterRequest
render #notification-template with (message: 'Exercise added') then append it to #notifications-container
then call _hyperscript.processNode(#notifications-container)
then reset() me">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="lg:col-span-1">
<label class="block text-xs font-bold text-gray-700 uppercase mb-2">
Exercise Name
</label>
<input
class="w-full bg-white text-gray-700 border border-gray-300 rounded-xl py-2.5 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 transition-all"
type="text" name="name" placeholder="e.g. Bench Press" required>
</div>
<div class="md:col-span-2 grid grid-cols-1 sm:grid-cols-3 gap-4">
{% for cat_name, options in all_attributes.items() %}
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-2">{{ cat_name
}}</label>
{{ render_partial('partials/custom_select.html',
name='attribute_ids',
options=options,
multiple=true,
search=true,
placeholder='Select ' ~ cat_name
)}}
</div>
{% endfor %}
</div>
<div class="lg:col-span-1 flex items-end">
<button
class="w-full flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-bold rounded-xl text-sm px-5 py-3 transition-all shadow-md active:scale-95 cursor-pointer"
type="submit">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<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"></path>
</svg>
Add Exercise
</button>
</div>
</div>
</form>
</div>
<!-- Category & Attribute Management Section -->
<div class="mt-12 pt-12 border-t border-gray-100">
<div class="mb-8">
<h4 class="text-lg font-bold text-gray-900">Manage Categories & Options</h4>
<p class="text-sm text-gray-500">Add or edit muscle groups, equipment types, and other exercise
attributes.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8" id="categories-admin-list">
{% for cat in categories_list %}
{% set options = all_attributes.get(cat.name, []) %}
{{ render_partial('partials/exercise/category_admin.html', category_id=cat.category_id,
name=cat.name, attributes=options) }}
{% endfor %}
</div>
<!-- Add New Category Form -->
<div class="mt-8 p-6 bg-gray-50/50 rounded-2xl border border-dashed border-gray-300">
<form hx-post="{{ url_for('exercises.create_category') }}" hx-target="#categories-admin-list"
hx-swap="beforeend" _="on htmx:afterRequest reset() me"
class="flex flex-col sm:flex-row items-center gap-4">
<div class="w-full sm:flex-1">
<label class="block text-xs font-bold text-gray-400 uppercase mb-2">New Category
Name</label>
<input type="text" name="name" placeholder="e.g. Difficulty, Intensity..."
class="w-full text-sm bg-white border border-gray-200 rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 outline-none transition-all shadow-sm"
required>
</div>
<div class="w-full sm:w-auto self-end">
<button type="submit"
class="w-full flex items-center justify-center text-white bg-gray-800 hover:bg-gray-900 font-bold rounded-xl text-sm px-6 py-2.5 transition-all shadow-md active:scale-95">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4">
</path>
</svg>
Create Category
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,48 @@
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-6">
<h3 class="text-xl font-bold text-gray-900">Data & Portability</h3>
<p class="text-sm text-gray-500">Export your data for backup or external analysis.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- CSV Export -->
<div class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
<div class="flex items-center mb-4">
<div class="p-3 bg-green-100 rounded-lg text-green-600 mr-4 shadow-sm">
<svg class="w-6 h-6" 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>
</div>
<h4 class="text-lg font-bold text-gray-900">Workout History</h4>
</div>
<p class="text-sm text-gray-600 mb-6 font-medium">Download all workout records, sets, and
performance data in CSV format.</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 shadow-sm">
Download CSV
</a>
</div>
<!-- SQL Export -->
<div class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
<div class="flex items-center mb-4">
<div class="p-3 bg-blue-100 rounded-lg text-blue-600 mr-4 shadow-sm">
<svg class="w-6 h-6" 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>
</div>
<h4 class="text-lg font-bold text-gray-900">Database Snapshot</h4>
</div>
<p class="text-sm text-gray-600 mb-6 font-medium">Create a full SQL dump of your database including
schema and all records.</p>
<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 shadow-sm">
Download SQL Script
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,87 @@
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-bold text-gray-900">User Management</h3>
<p class="text-sm text-gray-500">Add, edit or remove people from the tracker.</p>
</div>
</div>
<div class="flex flex-col">
<div class="overflow-x-auto rounded-lg">
<div class="align-middle inline-block min-w-full">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col"
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<div class="relative max-w-xs ml-auto">
<div
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
</svg>
</div>
<input type="search" id="people-search"
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
placeholder="Search users..."
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
</div>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200" id="new-person" hx-target="closest tr"
hx-swap="outerHTML swap:0.5s">
{% for p in people %}
{{ render_partial('partials/person.html', person_id=p['PersonId'], name=p['Name'])}}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<form class="w-full mt-6 bg-gray-50 p-4 rounded-lg border border-gray-100" hx-post="{{ url_for('create_person') }}"
hx-swap="beforeend" hx-target="#new-person" _="on htmx:afterRequest
render #notification-template with (message: 'User added') then append it to #notifications-container
then call _hyperscript.processNode(#notifications-container)
then reset() me">
<div class="flex flex-col sm:flex-row gap-4 items-end justify-end">
<div class="grow w-full sm:w-auto max-w-sm">
<label class="block text-sm font-medium text-gray-700 mb-1" for="person-name">
New user
</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clip-rule="evenodd" />
</svg>
</div>
<input id="person-name"
class="focus:ring-cyan-500 focus:border-cyan-500 block w-full pl-10 p-2 sm:text-sm border-gray-300 rounded-lg bg-gray-50"
type="text" name="name" placeholder="Full Name">
</div>
</div>
<button
class="w-full sm:w-auto flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-2.5 transition-colors shadow-sm"
type="submit">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<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"></path>
</svg>
Add User
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,17 @@
<div class="w-full h-full bg-gray-100 rounded-lg animate-pulse relative overflow-hidden">
<!-- Subtle shimmer effect -->
<div
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-[shimmer_2s_infinite]">
</div>
<div class="flex items-center justify-center h-full">
<div class="text-xs text-gray-400 font-medium">Loading...</div>
</div>
</div>
<style>
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -2,32 +2,32 @@
{% set margin = 2 %}
{% macro path(data_points, vb_height) %}
{% for value, position in data_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
{% endfor %}
{% for value, position in data_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
{% endfor %}
{% endmacro %}
{% macro path_best_fit(best_fit_points, vb_height) %}
{% for value, position in best_fit_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
{% endfor %}
{% for value, position in best_fit_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
{% endfor %}
{% endmacro %}
{% macro circles(data_points, color) %}
{% for value, position in data_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1"></circle>
{% endfor %}
{% for value, position in data_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1"></circle>
{% endfor %}
{% endmacro %}
{% macro plot_line(points, color) %}
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
{{ circles(points, color) }}
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
{{ circles(points, color) }}
{% endmacro %}
<!-- HubSpot doesn't escape whitespace. -->
@@ -44,194 +44,167 @@
on mouseout from .pnt-{{ unique_id }}
add .hidden to #popover-{{ unique_id }}">
<div id="popover-{{ unique_id }}" class="absolute t-0 r-0 hidden bg-white border border-gray-300 p-2 z-10">
<!-- Popover content will be dynamically inserted here -->
<!-- Popover content will be dynamically inserted here -->
</div>
<h4 class="text-l font-semibold text-blue-400 text-center">{{ title }}</h4>
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center" style='font-family: "Computer Modern Sans", sans-serif;'>
<div class="flex items-center justify-between sm:justify-center relative mb-1">
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2">
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4>
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center">
</div>
</div>
<div class="absolute left-0 z-10">
<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 History"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=latest_topset_id) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" 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="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
<span class="sr-only">Show History</span>
</button>
</div>
</div>
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
{% if best_fit_formula %}
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
{% endif %}
</h2>
<div class="inline-flex rounded-md shadow-sm w-full items-center justify-center mb-1">
{% for epoch in epochs %}
<div
{% if selected_epoch == epoch %}
class="px-4 py-2 text-sm font-medium text-blue-700 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"
<div {% if selected_epoch==epoch %}
class="px-4 py-2 text-sm font-medium text-blue-700 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"
{% else %}
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white cursor-pointer"
hx-get='{{ url_for("get_exercise_progress_for_user", person_id=person_id, exercise_id=exercise_id, epoch=epoch) }}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="click"
{% endif %}>
{% if epoch == 'Custom' %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 {% if selected_epoch == 'Custom' %}text-blue-700{% else %}text-gray-500{% endif %} group-hover:text-gray-900">
<path d="M10 3.75a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM17.25 4.5a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM5 3.75a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 .75.75ZM4.25 17a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM17.25 17a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM9 10a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1 0-1.5h5.5A.75.75 0 0 1 9 10ZM17.25 10.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM14 10a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM10 16.25a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z" />
</svg>
{% else %}
{{ epoch}}
{% endif %}
</div>
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white cursor-pointer"
hx-get='{{ url_for("get_exercise_progress_for_user", person_id=person_id, exercise_id=exercise_id, epoch=epoch) }}'
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="click" {% endif %}>
{% if epoch == 'Custom' %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="w-5 h-5 {% if selected_epoch == 'Custom' %}text-blue-700{% else %}text-gray-500{% endif %} group-hover:text-gray-900">
<path
d="M10 3.75a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM17.25 4.5a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM5 3.75a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 .75.75ZM4.25 17a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM17.25 17a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM9 10a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1 0-1.5h5.5A.75.75 0 0 1 9 10ZM17.25 10.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM14 10a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM10 16.25a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z" />
</svg>
{% else %}
{{ epoch}}
{% endif %}
</div>
{% endfor %}
</div>
{% if selected_epoch == 'Custom' %}
<div class="flex flex-col md:flex-row justify-center pb-4">
<!-- Min Date -->
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
<label
class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
for="grid-city"
>
Min date
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
Min date
</label>
<div class="relative pr-2">
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<svg
aria-hidden="true"
class="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<svg aria-hidden="true" class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="currentColor"
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2
2 0 002 2h12a2 2 0 002-2V6a2 2 0
00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1
1 0 00-1-1zm0 5a1 1 0 000
2h8a1 1 0 100-2H6z"
clip-rule="evenodd"
></path>
</svg>
</div>
<input
type="date"
class="bg-gray-50 border border-gray-300 text-gray-900
2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
</div>
<input type="date" class="bg-gray-50 border border-gray-300 text-gray-900
text-sm rounded-lg focus:ring-blue-500
focus:border-blue-500 block w-full pl-10 p-2.5"
name="min_date"
value="{{ min_date }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-include="#svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-vals='{"epoch": "Custom"}'
hx-target="#svg-plot-{{ unique_id }}"
hx-swap="outerHTML"
hx-trigger="change"
>
focus:border-blue-500 block w-full pl-10 p-2.5" name="min_date" value="{{ min_date }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-include="#svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
</div>
</div>
<!-- Max Date -->
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
<label
class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
for="grid-zip"
>
Max date
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Max date
</label>
<div class="relative pr-2">
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<svg
aria-hidden="true"
class="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0
<svg aria-hidden="true" class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="currentColor"
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0
00-2 2v10a2 2 0 002 2h12a2
2 0 002-2V6a2 2 0 00-2-2h-1V3a1
1 0 10-2 0v1H7V3a1 1 0
00-1-1zm0 5a1 1 0 000
2h8a1 1 0 100-2H6z"
clip-rule="evenodd"
></path>
2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
</div>
<input
type="date"
class="bg-gray-50 border border-gray-300
<input type="date" class="bg-gray-50 border border-gray-300
text-gray-900 text-sm rounded-lg
focus:ring-blue-500 focus:border-blue-500
block w-full pl-10 p-2.5"
name="max_date"
value="{{ max_date }}"
block w-full pl-10 p-2.5" name="max_date" value="{{ max_date }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-vals='{"epoch": "Custom"}'
hx-target="#svg-plot-{{ unique_id }}"
hx-swap="outerHTML"
hx-trigger="change"
>
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
</div>
</div>
<!-- Degree -->
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
<label
class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
for="grid-zip"
>
Degree
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Degree
</label>
<div class="relative pr-2">
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<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 text-gray-500 dark:text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
<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 text-gray-500 dark:text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
</div>
<input type="number"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 w-full"
name="degree"
value="{{ degree }}"
min="1"
step="1"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"
hx-vals='{"epoch": "Custom"}'
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="change">
</div>
<input type="number"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 w-full"
name="degree" value="{{ degree }}" min="1" step="1"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
</div>
</div>
</div>
</div>
{% endif %}
<svg viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none">
{% for plot in plots %}
<g class="{{ plot.label }}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
{{ plot_line(plot.points, plot.color) }}
{{ plot_line(plot.points, plot.color) }}
</g>
{% endfor %}
<g style="fill-opacity: 0%">
{% for pos, message in plot_labels %}
{% for pos, message in plot_labels %}
{% set x = (pos * vb_width) - (stroke_width/2) + margin %}
{% set y = 0 %}
{% set width = stroke_width %}
{% set height = vb_height + margin %}
<rect
x="{{ x | int }}"
y="{{ y | int }}"
width="{{ width | int }}"
height="{{ height | int }}"
class="pnt-{{ unique_id }}"
data-msg="{{ message }}"></rect>
{% endfor %}
<rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}"
class="pnt-{{ unique_id }}" data-msg="{{ message }}"></rect>
{% endfor %}
</g>
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none" stroke-opacity="60%"/>
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none"
stroke-opacity="60%" />
</svg>
<div class="flex justify-center pt-2">
{% for plot in plots %}
<div class="flex items-center px-2 select-none cursor-pointer"
_="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
<div class="flex items-center px-2 select-none cursor-pointer" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
on click toggle .hidden on my.plot_line then toggle .line-through on me">
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
<div class="text-xs">{{ plot.label }}</div>
</div>
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
<div class="text-xs">{{ plot.label }}</div>
</div>
{% endfor %}
</div>
</div>
</div>

View File

@@ -1,43 +1,56 @@
{% if error or results %}
<div class="relative">
<div class="mt-12 bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-lg animate-fadeIn relative">
<!-- Floating Clear Button -->
<button _="on click set the innerHTML of my.parentElement to ''"
class="absolute top-0 right-0 m-2 px-3 py-2 flex items-center gap-2 rounded-full bg-gray-800 text-white shadow-md opacity-50 hover:opacity-100 hover:bg-gray-700 transition-all">
<!-- Trash Icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
<path
d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m5 4v6m4-6v6" />
<button _="on click transition opacity to 0 then set my.parentElement.innerHTML to ''"
class="absolute top-4 right-4 p-2 bg-gray-900/10 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-full transition-all duration-200 group z-10"
title="Clear results">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5-4h4a2 2 0 012 2v1H7V5a2 2 0 012-2z" />
</svg>
<span>Clear</span>
</button>
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
<h3 class="text-sm font-bold text-gray-700 uppercase tracking-wider">Query Results</h3>
{% if results %}
<p class="text-xs text-gray-500 mt-0.5">{{ results|length }} rows returned</p>
{% endif %}
</div>
{% if error %}
<div class="bg-red-200 text-red-800 p-4 rounded mb-4">
<strong>Error:</strong> {{ error }}
<div class="p-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4 rounded text-red-700 text-sm">
<strong class="font-bold">Execution Error:</strong> {{ error }}
</div>
</div>
{% endif %}
{% if results %}
<table class="min-w-full bg-white">
<thead>
<tr>
{% for col in columns %}
<th class="py-2 px-4 border-b">{{ col }}</th>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 table-zebra">
<thead class="bg-gray-50/30">
<tr>
{% for col in columns %}
<th scope="col"
class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-widest border-b border-gray-100">
{{ col }}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
{% for row in results %}
<tr class="hover:bg-blue-50/30 transition-colors">
{% for col in columns %}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">
{{ row[col] if row[col] is not none else 'NULL' }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in results %}
<tr class="text-center">
{% for col in columns %}
<td class="py-2 px-4 border-b">{{ row[col] }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
{% endif %}
</div>
{% endif %}

View File

@@ -1,23 +1,51 @@
<div class="relative">
<div class="relative space-y-4">
<!-- Hidden textarea containing the actual SQL (so we preserve line breaks) -->
<textarea id="create_sql_text" style="display: none;">{{ create_sql }}</textarea>
<textarea id="create_sql_text" class="hidden">{{ create_sql }}</textarea>
<!-- Floating Clear Button -->
<button onclick="copySqlToClipboard()"
class="absolute top-0 right-0 m-2 px-3 py-2 flex items-center gap-2 rounded-full bg-gray-800 text-white shadow-md opacity-50 hover:opacity-100 hover:bg-gray-700 transition-all">
<!-- Floating Actions Container -->
<div class="absolute top-4 right-4 flex items-center gap-2 z-10">
<button id="copy-ddl-btn" onclick="copySqlToClipboard()"
_="on click set my.innerText to 'Copied!' then wait 2s then set my.innerText to 'Copy DDL SQL'"
class="px-4 py-2 flex items-center gap-2 rounded-xl bg-gray-900 text-white shadow-lg hover:bg-gray-800 transition-all text-sm font-medium border border-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012-2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
<span>Copy DDL SQL</span>
</button>
</div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
</svg>
<!-- Schema Diagram Frame -->
<div class="overflow-auto border-2 border-dashed border-gray-200 rounded-2xl bg-slate-50 p-8 shadow-inner"
style="max-height: 80vh;">
<div class="flex justify-center min-w-max">
<div class="bg-white p-4 rounded-2xl shadow-xl border border-gray-100">
<object data="/static/img/schema.svg" type="image/svg+xml" id="schema-svg-object"
class="block transition-all duration-300"
style="min-width: 1000px; height: auto; min-height: 600px;">
<p class="text-gray-500">Your browser does not support SVG objects.
<a href="/static/img/schema.svg" target="_blank" class="text-blue-500 hover:underline">Click
here to view the schema directly.</a>
</p>
</object>
</div>
</div>
</div>
<span>Copy SQL</span>
</button>
<div class="overflow-auto border rounded-xl bg-slate-50 p-4" style="max-height: 80vh;">
<div class="flex justify-center">
<img src="/static/img/schema.svg" alt="Database Schema Diagram" class="max-w-full h-auto">
<!-- Schema Footer Info -->
<div class="flex items-center justify-center gap-4 text-xs font-medium text-gray-400">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
Primary Keys
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
Foreign Keys
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-gray-300"></span>
Columns
</div>
</div>
@@ -27,27 +55,23 @@
const text = textArea.value;
if (navigator.clipboard && navigator.clipboard.writeText) {
// Modern approach: Use Clipboard API
navigator.clipboard.writeText(text)
.then(() => {
alert("SQL copied to clipboard!");
// We could use a toast here if available
console.log("SQL copied to clipboard!");
})
.catch(err => {
alert("Failed to copy: " + err);
console.error("Failed to copy: " + err);
});
} else {
// Fallback (older browsers):
// - Temporarily show the textarea, select, and use document.execCommand('copy')
// - This approach is less reliable but widely supported before navigator.clipboard.
textArea.style.display = "block"; // show temporarily
textArea.classList.remove('hidden');
textArea.select();
try {
document.execCommand("copy");
alert("SQL copied to clipboard!");
} catch (err) {
alert("Failed to copy: " + err);
console.error("Failed to copy: " + err);
}
textArea.style.display = "none"; // hide again
textArea.classList.add('hidden');
}
}
</script>

View File

@@ -1,199 +1,208 @@
<div id="sql-query">
<div id="sql-query" class="space-y-8">
{% if error %}
<div class="bg-red-200 text-red-800 p-3 rounded mb-4">
<strong>Error:</strong> {{ error }}
<div class="bg-red-50 border-l-4 border-red-400 p-4 rounded shadow-sm animate-fadeIn">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700">
<strong class="font-bold">Error:</strong> {{ error }}
</p>
</div>
</div>
</div>
{% endif %}
<form method="POST" hx-post="{{ url_for('sql_explorer.sql_query') }}" hx-target="#sql-query">
<!-- Title Input -->
<div>
<label for="query-title" class="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="query-title" name="title"
class="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter a title for your query" {% if title %} value="{{ title }}" {% endif %}>
</div>
<div class=" pt-2">
<label for="query" class="block text-sm font-medium text-gray-700 pb-1">Query</label>
<textarea name="query" spellcheck="false" id="query"
class="w-full h-48 p-4 font-mono text-sm text-gray-800 bg-gray-100 border border-gray-300 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your SQL query here..." required
_="on load set my.style.height to my.scrollHeight + 'px'
on input set my.style.height to 0 then set my.style.height to my.scrollHeight + 'px'">{{ query }}</textarea>
</div>
<!-- Natural Language Query Input -->
<div class="pt-2">
<label for="natural-query" class="block text-sm font-medium text-gray-700 pb-1">Generate SQL from Natural
Language</label>
<div class="flex items-center">
<input type="text" id="natural-query" name="natural_query"
class="flex-grow p-2 border border-gray-300 rounded-l-md shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="e.g., 'Show me the number of workouts per person'">
<button type="button" hx-post="{{ url_for('sql_explorer.generate_sql') }}"
hx-include="[name='natural_query']" hx-indicator="#sql-spinner" hx-swap="none"
_="on htmx:afterRequest set #query.value to detail.xhr.responseText then send input to #query"
class="bg-purple-600 text-white p-2.5 rounded-r-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 inline-flex items-center">
Generate SQL
<span id="sql-spinner" class="htmx-indicator ml-2">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
<form method="POST" hx-post="{{ url_for('sql_explorer.sql_query') }}" hx-target="#sql-query" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Title Input -->
<div class="space-y-1">
<label for="query-title" class="block text-sm font-semibold text-gray-700">Query Title</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</span>
</button>
</div>
<input type="text" id="query-title" name="title"
class="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Untitled Query" {% if title %} value="{{ title }}" {% endif %}>
</div>
</div>
<!-- Magic SQL Generator -->
<div class="space-y-1">
<label for="natural-query" class="block text-sm font-semibold text-gray-700">AI SQL Generator</label>
<div class="flex items-center gap-2">
<div class="relative flex-grow">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-purple-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 9.134a1 1 0 010 1.732l-3.354 1.935-1.18 4.455a1 1 0 01-1.933 0L9.854 12.8 6.5 10.866a1 1 0 010-1.732l3.354-1.935 1.18-4.455A1 1 0 0112 2z"
clip-rule="evenodd" />
</svg>
</div>
<input type="text" id="natural-query" name="natural_query"
class="block w-full pl-9 pr-3 py-2.5 border border-purple-200 rounded-xl shadow-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm bg-purple-50/30 placeholder-purple-300"
placeholder="e.g. 'Workouts per person last month'">
</div>
<button type="button" hx-post="{{ url_for('sql_explorer.generate_sql') }}"
hx-include="[name='natural_query']" hx-indicator="#sql-spinner" hx-swap="none"
_="on htmx:afterRequest set #query.value to detail.xhr.responseText then send input to #query"
class="btn-premium whitespace-nowrap inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-xl text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 shadow-sm transition-all">
Generate
<span id="sql-spinner" class="htmx-indicator ml-2">
<svg class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</span>
</button>
</div>
</div>
</div>
<!-- SQL Editor -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label for="query" class="block text-sm font-semibold text-gray-700">SQL Statement</label>
<span class="text-xs text-gray-400 font-mono">PostgreSQL Dialect</span>
</div>
<div class="sql-editor-container border border-gray-800 shadow-lg">
<textarea name="query" spellcheck="false" id="query"
class="sql-editor-textarea h-64 p-4 text-sm resize-none"
placeholder="SELECT * FROM workouts LIMIT 10;" required
_="on load set my.style.height to Math.max(256, my.scrollHeight) + 'px'
on input set my.style.height to 0 then set my.style.height to Math.max(256, my.scrollHeight) + 'px'">{{ query }}</textarea>
</div>
</div>
<!-- Action Buttons -->
<div class="flex space-x-2 pt-1">
<div class="flex flex-wrap items-center gap-3 pt-2">
<!-- Execute Button -->
<button hx-post="{{ url_for('sql_explorer.execute_sql_query') }}" hx-target="#execute-query-results"
hx-include="[name='query']" hx-trigger="click" hx-swap="innerHTML"
class="flex items-center bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<!-- Execute Icon (Heroicon: Play) -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M14.752 11.168l-5.197-2.132A1 1 0 008 9.868v4.264a1 1 0 001.555.832l5.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
class="btn-premium inline-flex items-center px-6 py-2.5 border border-transparent text-sm font-semibold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clip-rule="evenodd" />
</svg>
Execute
Execute Query
</button>
<!-- Plot Button -->
<button hx-post="{{ url_for('sql_explorer.plot_unsaved_query') }}" hx-target="#sql-plot-results"
hx-trigger="click" hx-include="[name='query'],[name='title']" hx-indicator="#sql-plot-results-loader"
class="flex items-center bg-blue-100 text-white px-4 py-2 rounded hover:bg-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-300">
<!-- Plot Icon (Heroicon: Chart Bar) -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="h-5 w-5 mr-1">
<path stroke-linecap=" round" stroke-linejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
class="btn-premium inline-flex items-center px-6 py-2.5 border border-gray-300 text-sm font-semibold rounded-xl text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-blue-500" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Plot
<!-- Overlay with Animated Spinner -->
<div id="sql-plot-results-loader" class="loading-indicator inset-0 opacity-35 pl-2">
<svg class="animate-spin h-5 w-5 text-white opacity-100" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="45" stroke="currentColor" stroke-width="10" stroke-linecap="round"
class="opacity-20"></circle>
<path d="M50,5 A45,45 0 0,1 95,50" stroke="currentColor" stroke-width="10"
stroke-linecap="round" class="opacity-75"></path>
Visualize Plot
<span id="sql-plot-results-loader" class="htmx-indicator ml-2">
<svg class="animate-spin h-3 w-3 text-blue-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
</span>
</button>
<!-- Save Button -->
<button type="submit" name="action" value="save"
class="flex items-center bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500">
<!-- Save Icon (Heroicon: Save) -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
class="btn-premium inline-flex items-center px-6 py-2.5 border border-transparent text-sm font-semibold rounded-xl text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h7a2 2 0 012 2v1" />
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
Save
Save Query
</button>
</div>
</form>
<!-- Sql query Results Section -->
<div id="execute-query-results" class="mt-6">
</div>
<!-- Plot Results Section -->
<div id="sql-plot-results" class="mt-8">
</div>
<!-- Query Results -->
<div id="execute-query-results" class="transition-all duration-300"></div>
<!-- Plot Results -->
<div id="sql-plot-results" class="transition-all duration-300"></div>
<!-- Saved Queries Section -->
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4">Saved Queries</h2>
<div class="pt-10 border-t border-gray-100">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">Saved Queries Library</h2>
<span class="text-xs font-medium text-gray-400 uppercase tracking-widest">{{ saved_queries|length }} Queries
Total</span>
</div>
{% if saved_queries %}
<div class="overflow-x-auto">
<table class="min-w-full bg-white shadow-md rounded-lg overflow-hidden">
<thead>
<div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
<table class="min-w-full table-zebra">
<thead class="bg-gray-50/50">
<tr>
<th
class="py-3 px-6 bg-gray-200 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
Title</th>
class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider border-b">
Query Title</th>
<th
class="py-3 px-6 bg-gray-200 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
Actions</th>
class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider border-b">
Quick Actions</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-100">
{% for saved in saved_queries %}
<tr class="hover:bg-gray-100 transition-colors duration-200">
<!-- Query Title as Load Action -->
<td class="py-4 px-6 border-b">
<a href="#" hx-get="{{ url_for('sql_explorer.load_sql_query', query_id=saved.id) }}"
<tr class="group transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<button hx-get="{{ url_for('sql_explorer.load_sql_query', query_id=saved.id) }}"
hx-target="#sql-query"
class="flex items-center text-blue-500 hover:text-blue-700 cursor-pointer">
<!-- Load Icon (Heroicon: Eye) -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none"
class="flex items-center text-sm font-medium text-gray-900 hover:text-blue-600 group-hover:translate-x-1 transition-all">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-2.5 text-gray-400 group-hover:text-blue-500" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{{ saved.title }}
</a>
{{ saved.title or 'Untitled Query' }}
</button>
</td>
<td class="py-4 px-6 border-b">
<div class="flex space-x-4">
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-4">
<!-- Plot Action -->
<a href="#" hx-get="{{ url_for('sql_explorer.plot_query', query_id=saved.id) }}"
hx-target="#sql-plot-results"
class="flex items-center text-green-500 hover:text-green-700 cursor-pointer"
hx-trigger="click" hx-indicator="#sql-plot-results-loader-{{ saved.id }}">
<!-- Plot Icon (Heroicon: Chart Bar) -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="h-5 w-5 mr-1">
<path stroke-linecap=" round" stroke-linejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
<button hx-get="{{ url_for('sql_explorer.plot_query', query_id=saved.id) }}"
hx-target="#sql-plot-results" hx-indicator="#sql-plot-results-loader-{{ saved.id }}"
class="text-green-600 hover:text-green-800 p-1 rounded-lg hover:bg-green-50 transition-colors tooltip"
title="Visualize Plot">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Plot
<!-- Overlay with Animated Spinner -->
<div id="sql-plot-results-loader-{{ saved.id }}"
class="loading-indicator inset-0 opacity-35 pl-2">
<svg class="animate-spin h-5 w-5 text-white opacity-100"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="45" stroke="currentColor" stroke-width="10"
stroke-linecap="round" class="opacity-20"></circle>
<path d="M50,5 A45,45 0 0,1 95,50" stroke="currentColor" stroke-width="10"
stroke-linecap="round" class="opacity-75"></path>
</svg>
</div>
</a>
</button>
<!-- Delete Action -->
<a href="#"
hx-delete="{{ url_for('sql_explorer.delete_sql_query', query_id=saved.id) }}"
hx-target="#sql-query"
class="flex items-center text-red-500 hover:text-red-700 cursor-pointer"
hx-confirm="Are you sure you want to delete the query titled '{{ saved.title }}'?">
<!-- Delete Icon (Heroicon: Trash) -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none"
<button hx-delete="{{ url_for('sql_explorer.delete_sql_query', query_id=saved.id) }}"
hx-target="#sql-query" hx-confirm="Delete query '{{ saved.title }}'?"
class="text-red-400 hover:text-red-600 p-1 rounded-lg hover:bg-red-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5-4h4a2 2 0 012 2v1H7V5a2 2 0 012-2z" />
</svg>
Delete
</a>
</button>
</div>
</td>
</tr>
@@ -202,8 +211,14 @@
</table>
</div>
{% else %}
<p class="text-gray-600">No saved queries found.</p>
<div class="text-center py-12 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No saved queries</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating and saving your first SQL query.</p>
</div>
{% endif %}
</div>
</div>

View File

@@ -6,18 +6,32 @@
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 class="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-1">
<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="#extra-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>
<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 History"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=topset_id) }}"
hx-target="#extra-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="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
<span class="sr-only">Show History</span>
</button>
</div>
</div>
{% else %}
<div class="w-full">
@@ -103,10 +117,10 @@
</div>
</td>
</tr>
{# Target row modified for dismissible graph #}
<tr id="graph-target-{{ topset_id }}">
{# Target row modified for dismissible extra content (graph or history) #}
<tr id="extra-target-{{ topset_id }}">
<td colspan="3" class="p-0 relative">
<div id="graph-content-{{ topset_id }}" class="graph-content-container" _="
<div id="extra-content-{{ topset_id }}" class="extra-content-container" _="
on htmx:afterSwap
get the next <button.dismiss-button/>
if my.innerHTML is not empty and my.innerHTML is not ' '
@@ -115,12 +129,12 @@
add .hidden to it
end
end">
<!-- Progress graph will be loaded here -->
<!-- Progress graph or history 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 }}
title="Dismiss Content" _="on click
get #extra-content-{{ topset_id }}
set its innerHTML to ''
add .hidden to me
end">
@@ -129,7 +143,7 @@
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>
<span class="sr-only">Dismiss Content</span>
</button>
</td>
</tr>

View File

@@ -0,0 +1,29 @@
{% for workout in workouts %}
<tr hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.id) }}" hx-push-url="true"
hx-target="#container" class="cursor-pointer">
<td class="p-4 whitespace-nowrap text-sm font-normal text-gray-500">
{{ workout.start_date | strftime("%b %d %Y") }}
</td>
{% for exercise in selected_exercises %}
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{% for set in workout.exercises[exercise.id] %}
{{ set.repetitions }} x {{ set.weight }}kg
{% endfor %}
</td>
{% endfor %}
</tr>
{% if loop.last and has_more %}
<tr id="load-more-row">
<td colspan="{{ selected_exercises|length + 1 }}" class="p-4 text-center">
<button class="text-blue-600 font-medium hover:underline px-4 py-2"
hx-get="{{ url_for('person_overview', person_id=person_id, offset=next_offset, limit=limit) }}"
hx-include="[name='exercise_id'],[name='min_date'],[name='max_date']" hx-target="#load-more-row"
hx-swap="outerHTML">
Load More Workouts
</button>
</td>
</tr>
{% endif %}
{% endfor %}

View File

@@ -105,7 +105,12 @@
<div class="mt-4 mb-4 w-full grid grid-cols-1 2xl:grid-cols-2 gap-4">
{% for graph in exercise_progress_graphs %}
{{ render_partial('partials/sparkline.html', **graph.progress_graph) }}
<div hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=graph.exercise_id, min_date=min_date, max_date=max_date) }}"
hx-trigger="intersect once" hx-swap="outerHTML">
<div class="h-48">
{{ render_partial('partials/skeleton_graph.html') }}
</div>
</div>
{% endfor %}
</div>
@@ -132,22 +137,7 @@
</thead>
<tbody class="bg-white">
{% for workout in workouts %}
<tr hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.id) }}"
hx-push-url="true" hx-target="#container" class="cursor-pointer">
<td class="p-4 whitespace-nowrap text-sm font-normal text-gray-500">
{{ workout.start_date | strftime("%b %d %Y") }}
</td>
{% for exercise in selected_exercises %}
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{% for set in workout.exercises[exercise.id] %}
{{ set.repetitions }} x {{ set.weight }}kg
{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
{% include 'partials/workout_rows.html' %}
</tbody>
</table>

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>

View File

@@ -7,12 +7,14 @@
<input type="radio" name="settings_tabs" id="radio-users" class="peer/users hidden" checked>
<input type="radio" name="settings_tabs" id="radio-exercises" class="peer/exercises hidden">
<input type="radio" name="settings_tabs" id="radio-export" class="peer/export hidden">
<input type="radio" name="settings_tabs" id="radio-activity" class="peer/activity hidden">
<!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6 bg-gray-50 z-10">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center text-gray-500">
<li class="mr-2">
<label for="radio-users"
<label for="radio-users" hx-get="{{ url_for('settings.settings_people') }}"
hx-target="#people-tab-content" hx-trigger="click"
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
peer-checked/users:border-cyan-600 peer-checked/users:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
@@ -24,7 +26,8 @@
</label>
</li>
<li class="mr-2">
<label for="radio-exercises"
<label for="radio-exercises" hx-get="{{ url_for('settings.settings_exercises') }}"
hx-target="#exercises-tab-content" hx-trigger="click"
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
peer-checked/exercises:border-cyan-600 peer-checked/exercises:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
@@ -38,7 +41,8 @@
</label>
</li>
<li class="mr-2">
<label for="radio-export"
<label for="radio-export" hx-get="{{ url_for('settings.settings_export') }}"
hx-target="#export-tab-content" hx-trigger="click"
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
peer-checked/export:border-cyan-600 peer-checked/export:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
@@ -50,256 +54,60 @@
Data & Export
</label>
</li>
<li class="mr-2">
<label for="radio-activity" hx-get="{{ url_for('settings.settings_activity') }}"
hx-target="#activity-tab-content" hx-trigger="click"
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
peer-checked/activity:border-cyan-600 peer-checked/activity:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd" />
</svg>
Activity
</label>
</li>
</ul>
</div>
<!-- Users Tab Content -->
<div class="hidden peer-checked/users:block">
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-bold text-gray-900">User Management</h3>
<p class="text-sm text-gray-500">Add, edit or remove people from the tracker.</p>
</div>
<div class="hidden peer-checked/users:block" id="people-tab-content"
hx-get="{{ url_for('settings.settings_people') }}" hx-trigger="load">
<div class="flex justify-center p-12">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
<p class="text-sm text-gray-500">Loading users...</p>
</div>
<div class="flex flex-col">
<div class="overflow-x-auto rounded-lg">
<div class="align-middle inline-block min-w-full">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col"
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<div class="relative max-w-xs ml-auto">
<div
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
</svg>
</div>
<input type="search" id="people-search"
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
placeholder="Search users..."
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
</div>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200" id="new-person" hx-target="closest tr"
hx-swap="outerHTML swap:0.5s">
{% for p in people %}
{{ render_partial('partials/person.html', person_id=p['PersonId'], name=p['Name'])}}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<form class="w-full mt-6 bg-gray-50 p-4 rounded-lg border border-gray-100"
hx-post="{{ url_for('create_person') }}" hx-swap="beforeend" hx-target="#new-person" _="on htmx:afterRequest
render #notification-template with (message: 'User added') then append it to #notifications-container
then call _hyperscript.processNode(#notifications-container)
then reset() me">
<div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="grow w-full sm:w-auto">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
for="person-name">
New user
</label>
<input id="person-name"
class="appearance-none block w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-3 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
type="text" name="name" placeholder="Full Name">
</div>
<button
class="w-full sm:w-auto flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-3 transition-colors shadow-sm"
type="submit">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<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"></path>
</svg>
Add User
</button>
</div>
</form>
</div>
</div>
<!-- Exercises Tab Content -->
<div class="hidden peer-checked/exercises:block">
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-6">
<h3 class="text-xl font-bold text-gray-900">Exercise Configuration</h3>
<p class="text-sm text-gray-500">Manage available exercises and their categories.</p>
</div>
<div class="flex flex-col">
<div class="overflow-x-auto rounded-lg">
<div class="align-middle inline-block min-w-full">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">
Name
</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/2">
Attributes
</th>
<th scope="col"
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">
<div class="relative max-w-xs ml-auto">
<div
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
</svg>
</div>
<input type="search" id="exercise-search"
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
placeholder="Search exercises..."
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
</div>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200" id="new-exercise"
hx-target="closest tr" hx-swap="outerHTML swap:0.5s">
{% for exercise in exercises %}
{{ render_partial('partials/exercise.html', exercise_id=exercise.exercise_id,
name=exercise.name, attributes=exercise.attributes)}}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-10">
<h4 class="text-lg font-semibold text-gray-900 mb-4">Add New Exercise</h4>
<form class="bg-gray-50 p-6 rounded-lg border border-gray-100"
hx-post="{{ url_for('create_exercise') }}" hx-swap="beforeend" hx-target="#new-exercise" _="on htmx:afterRequest
render #notification-template with (message: 'Exercise added') then append it to #notifications-container
then call _hyperscript.processNode(#notifications-container)
then reset() me">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
<div class="lg:col-span-1">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
Exercise Name
</label>
<input
class="appearance-none block w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-3 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
type="text" name="name" placeholder="e.g. Bench Press">
</div>
<div class="lg:col-span-2 grid grid-cols-1 sm:grid-cols-3 gap-4">
{% for cat_name, options in all_attributes.items() %}
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{ cat_name
}}</label>
{{ render_partial('partials/custom_select.html',
name='attribute_ids',
options=options,
multiple=true,
search=true,
placeholder='Select ' ~ cat_name
)}}
</div>
{% endfor %}
</div>
<div class="lg:col-span-1 pt-6">
<button
class="w-full flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-3 transition-colors h-12 cursor-pointer shadow-sm"
type="submit">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<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"></path>
</svg>
Add Exercise
</button>
</div>
</div>
</form>
<div class="hidden peer-checked/exercises:block" id="exercises-tab-content">
<div class="flex justify-center p-12">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
<p class="text-sm text-gray-500">Loading exercises...</p>
</div>
</div>
</div>
<!-- Export Tab Content -->
<div class="hidden peer-checked/export:block">
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-6">
<h3 class="text-xl font-bold text-gray-900">Data & Portability</h3>
<p class="text-sm text-gray-500">Export your data for backup or external analysis.</p>
<div class="hidden peer-checked/export:block" id="export-tab-content">
<div class="flex justify-center p-12">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
<p class="text-sm text-gray-500">Loading data settings...</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- CSV Export -->
<div
class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
<div class="flex items-center mb-4">
<div class="p-3 bg-green-100 rounded-lg text-green-600 mr-4 shadow-sm">
<svg class="w-6 h-6" 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>
</div>
<h4 class="text-lg font-bold text-gray-900">Workout History</h4>
</div>
<p class="text-sm text-gray-600 mb-6 font-medium">Download all workout records, sets, and
performance data in CSV format.</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 shadow-sm">
Download CSV
</a>
</div>
<!-- SQL Export -->
<div
class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
<div class="flex items-center mb-4">
<div class="p-3 bg-blue-100 rounded-lg text-blue-600 mr-4 shadow-sm">
<svg class="w-6 h-6" 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>
</div>
<h4 class="text-lg font-bold text-gray-900">Database Snapshot</h4>
</div>
<p class="text-sm text-gray-600 mb-6 font-medium">Create a full SQL dump of your database including
schema and all records.</p>
<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 shadow-sm">
Download SQL Script
</a>
</div>
<!-- Activity Tab Content -->
<div class="hidden peer-checked/activity:block" id="activity-tab-content">
<div class="flex justify-center p-12">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
<p class="text-sm text-gray-500">Loading activity history...</p>
</div>
</div>
</div>

View File

@@ -2,17 +2,74 @@
{% block content %}
<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 class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">SQL Explorer</h3>
<h1 class="text-3xl font-extrabold text-gray-900 tracking-tight sm:text-4xl">
SQL <span class="text-blue-600">Explorer</span>
</h1>
<p class="mt-2 text-sm text-gray-500 max-w-2xl">
Query your workout data directly using SQL or natural language. Explore the database schema below to
understand the available tables and relationships.
</p>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200">
<span class="flex h-2 w-2 mr-1.5 space-x-1">
<span class="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-blue-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
PostgreSQL Connected
</span>
</div>
</div>
<div hx-get="{{ url_for('sql_explorer.sql_schema') }}" hx-trigger="load"></div>
{{ render_partial('partials/sql_explorer/sql_query.html', saved_queries=saved_queries) }}
<div class="grid grid-cols-1 gap-8">
<!-- Schema Section -->
<section
class="bg-white shadow-sm border border-gray-200 rounded-2xl overflow-hidden transition-all hover:shadow-md">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h3 class="text-lg font-semibold text-gray-800">Database Schema</h3>
</div>
<button class="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors" _="on click toggle .hidden on #schema-content then
if #schema-content.classList.contains('hidden') set my.innerText to 'Show Schema'
else set my.innerText to 'Hide Schema'">
Hide Schema
</button>
</div>
<div id="schema-content" class="p-6 transition-all duration-300">
<div hx-get="{{ url_for('sql_explorer.sql_schema') }}" hx-trigger="load">
<!-- Loader placeholder -->
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
</div>
</div>
</section>
<!-- Query Section -->
<section
class="bg-white shadow-sm border border-gray-200 rounded-2xl overflow-hidden transition-all hover:shadow-md">
<div class="px-6 py-4 border-b border-gray-100 flex items-center gap-2 bg-gray-50/50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<h3 class="text-lg font-semibold text-gray-800">SQL Query Editor</h3>
</div>
<div class="p-6">
{{ render_partial('partials/sql_explorer/sql_query.html', saved_queries=saved_queries) }}
</div>
</section>
</div>
</div>
{% endblock %}

View File

@@ -34,10 +34,10 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
vb_height *= 75 / vb_height # Scale to 75px height
# Use NumPy arrays for efficient scaling
relative_positions = np.array([(date - min_date).days / total_span for date in start_dates])
estimated_1rm_scaled = ((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height
repetitions_scaled = ((np.array(repetitions) - min_reps) / reps_range) * vb_height
weight_scaled = ((np.array(weight) - min_weight) / weight_range) * vb_height
relative_positions = np.round(np.array([(date - min_date).days / total_span for date in start_dates]), 1)
estimated_1rm_scaled = np.round(((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height, 1)
repetitions_scaled = np.round(((np.array(repetitions) - min_reps) / reps_range) * vb_height, 1)
weight_scaled = np.round(((np.array(weight) - min_weight) / weight_range) * vb_height, 1)
# Calculate slope and line of best fit
slope_kg_per_day = e1rm_range / total_span
@@ -48,18 +48,22 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
best_fit_points = []
try:
if len(relative_positions) > 1: # Ensure there are enough points for polyfit
# Fit a polynomial of the given degree
coeffs = np.polyfit(relative_positions, estimated_1rm_scaled, degree)
# Filter out NaNs if any (though scaled values shouldn't have them if ranges are correct)
mask = ~np.isnan(estimated_1rm_scaled)
x_fit = relative_positions[mask]
y_fit = estimated_1rm_scaled[mask]
# Ensure we have enough unique X positions for the given degree
if len(np.unique(x_fit)) > degree:
coeffs = np.polyfit(x_fit, y_fit, degree)
poly_fit = np.poly1d(coeffs)
y_best_fit = poly_fit(relative_positions)
y_best_fit = np.round(poly_fit(relative_positions), 1)
best_fit_points = list(zip(y_best_fit.tolist(), relative_positions.tolist()))
else:
raise ValueError("Not enough data points for polyfit")
except (np.linalg.LinAlgError, ValueError) as e:
# Handle cases where polyfit fails
best_fit_points = []
except (np.linalg.LinAlgError, ValueError, TypeError) as e:
# Handle cases where polyfit fails or input is invalid
best_fit_points = []
m, b = 0, 0
# Prepare data for plots
repetitions_data = {
@@ -254,20 +258,6 @@ def prepare_svg_plot_data(results, columns, title):
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
return plot_data
def get_client_ip():
"""Get real client IP address, checking proxy headers first"""
# Check common proxy headers in order of preference
if request.headers.get('X-Forwarded-For'):
# X-Forwarded-For can contain multiple IPs, get the first (original client)
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
elif request.headers.get('X-Real-IP'):
return request.headers.get('X-Real-IP')
elif request.headers.get('CF-Connecting-IP'): # Cloudflare
return request.headers.get('CF-Connecting-IP')
else:
# Fallback to direct connection IP
return request.remote_addr
# Calculate ranges (handle datetime separately)
if x_type == 'datetime':
valid_dates = [d for d in x_values_raw if d is not None]
@@ -356,4 +346,18 @@ def get_client_ip():
plot_data['bar_width'] = draw_width / len(points) * 0.8 if points else 10
return plot_data
return plot_data
def get_client_ip():
"""Get real client IP address, checking proxy headers first"""
# Check common proxy headers in order of preference
if request.headers.get('X-Forwarded-For'):
# X-Forwarded-For can contain multiple IPs, get the first (original client)
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
elif request.headers.get('X-Real-IP'):
return request.headers.get('X-Real-IP')
elif request.headers.get('CF-Connecting-IP'): # Cloudflare
return request.headers.get('CF-Connecting-IP')
else:
# Fallback to direct connection IP
return request.remote_addr

710
uv.lock generated Normal file
View File

@@ -0,0 +1,710 @@
version = 1
revision = 3
requires-python = ">=3.14.0"
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "brotli"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/18/70c32fe9357f3eea18598b23aa9ed29b1711c3001835f7cf99a9818985d0/Brotli-1.0.9.zip", hash = "sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438", size = 510202, upload-time = "2020-08-27T14:27:08.104Z" }
[[package]]
name = "brotlicffi"
version = "1.2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
{ url = "https://files.pythonhosted.org/packages/1b/37/293a9a0a7caf17e6e657668bebb92dfe730305999fe8c0e2703b8888789c/brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6", size = 343085, upload-time = "2025-11-21T18:17:48.887Z" },
{ url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586, upload-time = "2025-11-21T18:17:50.531Z" },
]
[[package]]
name = "cachelib"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48", size = 34418, upload-time = "2024-04-13T14:18:27.782Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", size = 20914, upload-time = "2024-04-13T14:18:26.361Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flask-bcrypt"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bcrypt" },
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/f4/25dccfafad391d305b63eb6031e7c1dbb757169d54d3a73292939201698e/Flask-Bcrypt-1.0.1.tar.gz", hash = "sha256:f07b66b811417ea64eb188ae6455b0b708a793d966e1a80ceec4a23bc42a4369", size = 5996, upload-time = "2022-04-05T03:59:52.682Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/72/af9a3a3dbcf7463223c089984b8dd4f1547593819e24d57d9dc5873e04fe/Flask_Bcrypt-1.0.1-py3-none-any.whl", hash = "sha256:062fd991dc9118d05ac0583675507b9fe4670e44416c97e0e6819d03d01f808a", size = 6050, upload-time = "2022-04-05T03:59:51.589Z" },
]
[[package]]
name = "flask-caching"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachelib" },
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e2/80/74846c8af58ed60972d64f23a6cd0c3ac0175677d7555dff9f51bf82c294/flask_caching-2.3.1.tar.gz", hash = "sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9", size = 67560, upload-time = "2025-02-23T01:34:40.207Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/bb/82daa5e2fcecafadcc8659ce5779679d0641666f9252a4d5a2ae987b0506/Flask_Caching-2.3.1-py3-none-any.whl", hash = "sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761", size = 28916, upload-time = "2025-02-23T01:34:37.749Z" },
]
[[package]]
name = "flask-compress"
version = "1.23"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "brotli", marker = "platform_python_implementation != 'PyPy'" },
{ name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" },
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/e4/2b54da5cf8ae5d38a495ca20154aa40d6d2ee6dc1756429a82856181aa2c/flask_compress-1.23.tar.gz", hash = "sha256:5580935b422e3f136b9a90909e4b1015ac2b29c9aebe0f8733b790fde461c545", size = 20135, upload-time = "2025-11-06T09:06:29.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/9a/bebdcdba82d2786b33cd9f5fd65b8d309797c27176a9c4f357c1150c4ac0/flask_compress-1.23-py3-none-any.whl", hash = "sha256:52108afb4d133a5aab9809e6ac3c085ed7b9c788c75c6846c129faa28468f08c", size = 10515, upload-time = "2025-11-06T09:06:28.691Z" },
]
[[package]]
name = "flask-htmx"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/b7/1ba8b722ccc12b72b44af949f438a85111ba8db9e39f973dff4a47da068e/flask_htmx-0.4.0.tar.gz", hash = "sha256:2d367fb27c8da99d031a0c566b7e562637139722e2d4e8ec67c7f941addb22fd", size = 5815, upload-time = "2024-09-22T04:14:20.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/8e/7e75c2210567ba11df9ea7d031eb5b8f45e82f6112cc8be885cb0ce86c7d/flask_htmx-0.4.0-py3-none-any.whl", hash = "sha256:ac0ef976638bc635537a47c4ae622c91aef1e69d8bf52880aa9ae0db089ce7d2", size = 6773, upload-time = "2024-09-22T04:14:18.41Z" },
]
[[package]]
name = "flask-login"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
]
[[package]]
name = "flask-wtf"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "itsdangerous" },
{ name = "wtforms" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/9b/f1cd6e41bbf874f3436368f2c7ee3216c1e82d666ff90d1d800e20eb1317/flask_wtf-1.2.2.tar.gz", hash = "sha256:79d2ee1e436cf570bccb7d916533fa18757a2f18c290accffab1b9a0b684666b", size = 42641, upload-time = "2024-10-24T07:18:58.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/19/354449145fbebb65e7c621235b6ad69bebcfaec2142481f044d0ddc5b5c5/flask_wtf-1.2.2-py3-none-any.whl", hash = "sha256:e93160c5c5b6b571cf99300b6e01b72f9a101027cab1579901f8b10c5daf0b70", size = 12779, upload-time = "2024-10-24T07:18:56.976Z" },
]
[[package]]
name = "gunicorn"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja-partials"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/30/21850ec17f6be1c2549b98a33ba1e90cff3697e1c111e7ed5c2b375cee33/jinja_partials-0.1.1.tar.gz", hash = "sha256:7afea85362f48c49b32c2134612edf52e5f3d5af6f2038fcdb758c53a50ee7af", size = 4681, upload-time = "2021-12-15T05:48:59.321Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/f8/2be19a6ee1d3895576c22fb25c42673072ea1a5eda779e4be1af914d45c7/jinja_partials-0.1.1-py3-none-any.whl", hash = "sha256:c927e0dd51cb299c41c96bd200e01be299f860466fc2d6bb6a67861acb08b498", size = 4635, upload-time = "2021-12-15T05:48:57.854Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jinja2-fragments"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/54/00b5d04130ff2bd6e4dca20a8fa9f8328aa3b3ce0b506bb87cd221ffe19f/jinja2_fragments-0.3.0.tar.gz", hash = "sha256:4068b47239f0bc2ca71ee2a4d9521a225ae8e3d4bb84a9ca89bd26a608812163", size = 7101, upload-time = "2022-09-18T23:30:04.306Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/0c/66c45e428d5929c3e101c75492bdd9f7d4dcf3a0524dbca37e5ee1862435/jinja2_fragments-0.3.0-py3-none-any.whl", hash = "sha256:0e93af60da2af334bfaa0fcc549e9d493bf466774c5f30b49ac085a1ffeaae3f", size = 6979, upload-time = "2022-09-18T23:30:00.979Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "minify-html"
version = "0.18.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/b7/83dc18bef0cd6f4268d1a63dd682730d3c1150d77a973a34c8de63610bdc/minify_html-0.18.1.tar.gz", hash = "sha256:43998530ef537701f003a8e908b756d78eff303c86b041a95855e290518ba79c", size = 96577, upload-time = "2025-10-25T22:27:18.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/a4/61b966701e1d5fb06a7564d17ac53cc5990b083649748a249833e73d3d6a/minify_html-0.18.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e34af8574ed701555561fcc29d14ff6e8969df5281d51b62cdf556ca0ca7a56e", size = 3061250, upload-time = "2025-10-25T23:04:30.374Z" },
{ url = "https://files.pythonhosted.org/packages/40/14/ee02ac4f89afa8b888d5fe36c2f6261831b0bb191d3579b68286a9ef6364/minify_html-0.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e93301610f6c78ff83cf9d556d779ed4dee1c8aadf45a12dc4b40cebbe477a2e", size = 2828096, upload-time = "2025-10-25T22:55:15.804Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d9/5e34d74abadf89e40caf5f06e9b52b49d96da1bff437b1f2f05aa454c665/minify_html-0.18.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3f167339638f26af34a56027b24e7e2daa03670b84a1ba661975d6d4536481", size = 2900061, upload-time = "2025-10-25T22:27:53.055Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b9/45023457cd150be87fa6893e4e524929f36a46a1de92b7ce95d40e685e0d/minify_html-0.18.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e862f89f1493c17fe74d8c7a75bbd480aa7784bbf47ec396d9db4871101f94e4", size = 3082816, upload-time = "2025-10-25T22:27:26.023Z" },
{ url = "https://files.pythonhosted.org/packages/a7/42/c5015b02b5ee8b8194870f3beace2b14ac0e197d754e43f0973a36a3c6df/minify_html-0.18.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:045dd5640e988cc385d350e224e13f609a606a6cf9fa5f5011a1d860d4ebe607", size = 3082224, upload-time = "2025-10-25T22:30:42.538Z" },
{ url = "https://files.pythonhosted.org/packages/6a/04/cf74fd1f980c42068d229e9657415b008b3a65504fb2fa22b09cdf579e88/minify_html-0.18.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:3a11a926b2c236f527d8295b7f6e20c41728bdf870732273e2471e8c693f6109", size = 3327448, upload-time = "2025-10-25T22:30:55.968Z" },
{ url = "https://files.pythonhosted.org/packages/67/22/35ed1e1f733573de2988924bebc7a6e7b37027e37e43e8a3ac35e00fd960/minify_html-0.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:41f46915ce2634dd70138488a96d6b36e8b8cc2c2ee2953d89c525658394500a", size = 3116545, upload-time = "2025-10-25T22:36:25.166Z" },
]
[[package]]
name = "numpy"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "polars"
version = "1.38.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/5e/208a24471a433bcd0e9a6889ac49025fd4daad2815c8220c5bd2576e5f1b/polars-1.38.1.tar.gz", hash = "sha256:803a2be5344ef880ad625addfb8f641995cfd777413b08a10de0897345778239", size = 717667, upload-time = "2026-02-06T18:13:23.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/49/737c1a6273c585719858261753da0b688454d1b634438ccba8a9c4eb5aab/polars-1.38.1-py3-none-any.whl", hash = "sha256:a29479c48fed4984d88b656486d221f638cba45d3e961631a50ee5fdde38cb2c", size = 810368, upload-time = "2026-02-06T18:11:55.819Z" },
]
[[package]]
name = "polars-runtime-32"
version = "1.38.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/4b/04d6b3fb7cf336fbe12fbc4b43f36d1783e11bb0f2b1e3980ec44878df06/polars_runtime_32-1.38.1.tar.gz", hash = "sha256:04f20ed1f5c58771f34296a27029dc755a9e4b1390caeaef8f317e06fdfce2ec", size = 2812631, upload-time = "2026-02-06T18:13:25.206Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/a2/a00defbddadd8cf1042f52380dcba6b6592b03bac8e3b34c436b62d12d3b/polars_runtime_32-1.38.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:18154e96044724a0ac38ce155cf63aa03c02dd70500efbbf1a61b08cadd269ef", size = 44108001, upload-time = "2026-02-06T18:11:58.127Z" },
{ url = "https://files.pythonhosted.org/packages/a7/fb/599ff3709e6a303024efd7edfd08cf8de55c6ac39527d8f41cbc4399385f/polars_runtime_32-1.38.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c49acac34cc4049ed188f1eb67d6ff3971a39b4af7f7b734b367119970f313ac", size = 40230140, upload-time = "2026-02-06T18:12:01.181Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8c/3ac18d6f89dc05fe2c7c0ee1dc5b81f77a5c85ad59898232c2500fe2ebbf/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef2ef2626a954e010e006cc8e4de467ecf32d08008f130cea1c78911f545323", size = 41994039, upload-time = "2026-02-06T18:12:04.332Z" },
{ url = "https://files.pythonhosted.org/packages/f2/5a/61d60ec5cc0ab37cbd5a699edb2f9af2875b7fdfdfb2a4608ca3cc5f0448/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5f7a8125e2d50e2e060296551c929aec09be23a9edcb2b12ca923f555a5ba", size = 45755804, upload-time = "2026-02-06T18:12:07.846Z" },
{ url = "https://files.pythonhosted.org/packages/91/54/02cd4074c98c361ccd3fec3bcb0bd68dbc639c0550c42a4436b0ff0f3ccf/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10d19cd9863e129273b18b7fcaab625b5c8143c2d22b3e549067b78efa32e4fa", size = 42159605, upload-time = "2026-02-06T18:12:10.919Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f3/b2a5e720cc56eaa38b4518e63aa577b4bbd60e8b05a00fe43ca051be5879/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61e8d73c614b46a00d2f853625a7569a2e4a0999333e876354ac81d1bf1bb5e2", size = 45336615, upload-time = "2026-02-06T18:12:14.074Z" },
{ url = "https://files.pythonhosted.org/packages/f1/8d/ee2e4b7de948090cfb3df37d401c521233daf97bfc54ddec5d61d1d31618/polars_runtime_32-1.38.1-cp310-abi3-win_amd64.whl", hash = "sha256:08c2b3b93509c1141ac97891294ff5c5b0c548a373f583eaaea873a4bf506437", size = 45680732, upload-time = "2026-02-06T18:12:19.097Z" },
{ url = "https://files.pythonhosted.org/packages/bf/18/72c216f4ab0c82b907009668f79183ae029116ff0dd245d56ef58aac48e7/polars_runtime_32-1.38.1-cp310-abi3-win_arm64.whl", hash = "sha256:6d07d0cc832bfe4fb54b6e04218c2c27afcfa6b9498f9f6bbf262a00d58cc7c4", size = 41639413, upload-time = "2026-02-06T18:12:22.044Z" },
]
[[package]]
name = "psycopg"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
]
[[package]]
name = "psycopg-pool"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" },
]
[[package]]
name = "pyarrow"
version = "23.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" },
{ url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" },
{ url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" },
{ url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" },
{ url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" },
{ url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" },
{ url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" },
{ url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" },
{ url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" },
{ url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "python-dateutil"
version = "2.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" },
]
[[package]]
name = "python-dotenv"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
]
[[package]]
name = "workout"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "brotli" },
{ name = "email-validator" },
{ name = "flask" },
{ name = "flask-bcrypt" },
{ name = "flask-caching" },
{ name = "flask-compress" },
{ name = "flask-htmx" },
{ name = "flask-login" },
{ name = "flask-wtf" },
{ name = "gunicorn" },
{ name = "jinja-partials" },
{ name = "jinja2" },
{ name = "jinja2-fragments" },
{ name = "minify-html" },
{ name = "numpy" },
{ name = "polars" },
{ name = "psycopg", extra = ["binary"] },
{ name = "psycopg-pool" },
{ name = "pyarrow" },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "werkzeug" },
{ name = "wtforms" },
]
[package.metadata]
requires-dist = [
{ name = "brotli", specifier = "==1.0.9" },
{ name = "email-validator", specifier = "==2.2.0" },
{ name = "flask", specifier = ">=3.0.0" },
{ name = "flask-bcrypt", specifier = ">=1.0.1" },
{ name = "flask-caching", specifier = ">=2.1.0" },
{ name = "flask-compress", specifier = ">=1.14" },
{ name = "flask-htmx", specifier = ">=0.4.0" },
{ name = "flask-login", specifier = ">=0.6.3" },
{ name = "flask-wtf", specifier = ">=1.2.1" },
{ name = "gunicorn", specifier = ">=21.2.0" },
{ name = "jinja-partials", specifier = "==0.1.1" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "jinja2-fragments", specifier = "==0.3.0" },
{ name = "minify-html", specifier = ">=0.15.0" },
{ name = "numpy", specifier = ">=1.26.0" },
{ name = "polars", specifier = ">=0.20.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.0.0" },
{ name = "psycopg-pool", specifier = ">=3.2.0" },
{ name = "pyarrow", specifier = ">=14.0.0" },
{ name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-dotenv", specifier = "==1.0.1" },
{ name = "requests", specifier = ">=2.31.0" },
{ name = "werkzeug", specifier = ">=3.0.0" },
{ name = "wtforms", specifier = ">=3.1.0" },
]
[[package]]
name = "wtforms"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/e4/633d080897e769ed5712dcfad626e55dbd6cf45db0ff4d9884315c6a82da/wtforms-3.2.1.tar.gz", hash = "sha256:df3e6b70f3192e92623128123ec8dca3067df9cfadd43d59681e210cfb8d4682", size = 137801, upload-time = "2024-10-21T11:34:00.108Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/c9/2088fb5645cd289c99ebe0d4cdcc723922a1d8e1beaefb0f6f76dff9b21c/wtforms-3.2.1-py3-none-any.whl", hash = "sha256:583bad77ba1dd7286463f21e11aa3043ca4869d03575921d1a1698d0715e0fd4", size = 152454, upload-time = "2024-10-21T11:33:58.44Z" },
]