Compare commits
70 Commits
26dda12fff
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff6a921550 | ||
|
|
57f7610963 | ||
|
|
a401c1a1ab | ||
|
|
b0b42c0d77 | ||
|
|
89d0a7fb12 | ||
|
|
37e56559a9 | ||
|
|
d9def5c6b6 | ||
|
|
ccb71c37a4 | ||
|
|
7aebf8284d | ||
|
|
28b542e618 | ||
|
|
fb07c1d8ed | ||
|
|
1c51bb6ced | ||
|
|
c4feaa97dd | ||
|
|
73e02a7b12 | ||
|
|
b31ab97cd4 | ||
|
|
895b813a35 | ||
|
|
67009c9603 | ||
|
|
8c08140ad0 | ||
|
|
31078b181a | ||
|
|
a6eca1b4ac | ||
|
|
ce28f7f749 | ||
|
|
31f738cfb3 | ||
|
|
0cd74f7207 | ||
|
|
ef91dc1fe4 | ||
|
|
a9f3dd4a38 | ||
|
|
3f3725d277 | ||
|
|
09d90b5a1e | ||
|
|
3fabde145d | ||
|
|
71a5ae590e | ||
|
|
b4121eada7 | ||
|
|
a6a71f3139 | ||
|
|
9998616946 | ||
|
|
c20f2e2f85 | ||
|
|
ec8d7f6825 | ||
|
|
2e79ad1b8b | ||
|
|
d223bdeebc | ||
|
|
9a2ce6754a | ||
|
|
afc5749c82 | ||
|
|
2d1509a0cd | ||
|
|
83c3cd83a6 | ||
|
|
db8d39d1eb | ||
|
|
437271bc8c | ||
|
|
ac093ec2e0 | ||
|
|
b26ae1e319 | ||
|
|
f53bf3d106 | ||
|
|
2b330e4743 | ||
|
|
bc2a350e90 | ||
|
|
a59cef5c95 | ||
|
|
d7c9f71d22 | ||
|
|
62080b97a4 | ||
|
|
32719cc141 | ||
|
|
32b7527576 | ||
|
|
9e20976591 | ||
|
|
8b276804b9 | ||
|
|
5d2f3986bd | ||
|
|
d03581bff4 | ||
|
|
78f4a53c49 | ||
|
|
e156dd30cc | ||
|
|
eada1a829b | ||
|
|
1c500328d1 | ||
|
|
14d29724f1 | ||
|
|
4dcf589b63 | ||
|
|
b6443bc1e2 | ||
|
|
ec12072a33 | ||
|
|
d72bb1f30f | ||
|
|
722ff4d8e5 | ||
|
|
cb08992e19 | ||
|
|
036d852aab | ||
|
|
e7520035c7 | ||
|
|
144e555abb |
@@ -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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14.0
|
||||
135
app.py
135
app.py
@@ -1,10 +1,17 @@
|
||||
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
|
||||
from flask_login import LoginManager, login_required, current_user
|
||||
import jinja_partials
|
||||
from jinja2_fragments import render_block
|
||||
from decorators import validate_person, validate_topset, validate_workout
|
||||
from decorators import (validate_person, validate_topset, validate_workout,
|
||||
require_ownership, get_auth_message, get_person_id_from_context, admin_required)
|
||||
from routes.auth import auth, get_person_by_id
|
||||
from routes.changelog import changelog_bp
|
||||
from routes.calendar import calendar_bp # Import the new calendar blueprint
|
||||
@@ -15,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)
|
||||
@@ -40,6 +52,17 @@ login_manager.login_message_category = 'info'
|
||||
def load_user(person_id):
|
||||
return get_person_by_id(person_id)
|
||||
|
||||
@login_manager.unauthorized_handler
|
||||
def unauthorized():
|
||||
from flask import flash
|
||||
person_id = get_person_id_from_context()
|
||||
msg = get_auth_message(request.endpoint, person_id)
|
||||
flash(msg, "info")
|
||||
|
||||
if request.headers.get('HX-Request'):
|
||||
return '', 200, {'HX-Redirect': url_for('auth.login')}
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
app.register_blueprint(auth, url_prefix='/auth')
|
||||
app.register_blueprint(changelog_bp, url_prefix='/changelog')
|
||||
app.register_blueprint(calendar_bp) # Register the calendar blueprint
|
||||
@@ -50,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):
|
||||
@@ -124,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)
|
||||
|
||||
@@ -135,37 +163,57 @@ 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"}
|
||||
|
||||
@ app.route("/person", methods=['POST'])
|
||||
@login_required
|
||||
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"}
|
||||
|
||||
|
||||
@ app.route("/person/<int:person_id>/delete", methods=['DELETE'])
|
||||
@login_required
|
||||
@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"}
|
||||
|
||||
|
||||
@ app.route("/person/<int:person_id>/edit_form", methods=['GET'])
|
||||
@login_required
|
||||
@validate_person
|
||||
@require_ownership
|
||||
def get_person_edit_form(person_id):
|
||||
name = db.get_person_name(person_id)
|
||||
return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True)
|
||||
|
||||
|
||||
@ app.route("/person/<int:person_id>/name", methods=['PUT'])
|
||||
@login_required
|
||||
@validate_person
|
||||
@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"}
|
||||
|
||||
|
||||
@@ -175,45 +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'])
|
||||
def create_exercise():
|
||||
name = request.form.get("name")
|
||||
new_exercise_id = db.create_exercise(name)
|
||||
return render_template('partials/exercise.html', exercise_id=new_exercise_id, name=name)
|
||||
|
||||
|
||||
@ 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)
|
||||
|
||||
|
||||
@ app.route("/exercise/<int:exercise_id>/edit_form", methods=['GET'])
|
||||
def get_exercise_edit_form(exercise_id):
|
||||
exercise = db.get_exercise(exercise_id)
|
||||
return render_template('partials/exercise.html', exercise_id=exercise_id, name=exercise['name'], is_edit=True)
|
||||
|
||||
|
||||
@ app.route("/exercise/<int:exercise_id>/update", methods=['PUT'])
|
||||
def update_exercise(exercise_id):
|
||||
new_name = request.form.get('name')
|
||||
db.update_exercise(exercise_id, new_name)
|
||||
return render_template('partials/exercise.html', exercise_id=exercise_id, name=new_name)
|
||||
|
||||
|
||||
""" @ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
|
||||
def delete_exercise(exercise_id):
|
||||
db.delete_exercise(exercise_id)
|
||||
return "" """
|
||||
|
||||
|
||||
@ app.route("/settings")
|
||||
def settings():
|
||||
people = db.get_people()
|
||||
exercises = db.get_all_exercises()
|
||||
if htmx:
|
||||
return render_block(app.jinja_env, "settings.html", "content", people=people, exercises=exercises), 200, {"HX-Trigger": "updatedPeople"}
|
||||
return render_template('settings.html', people=people, exercises=exercises)
|
||||
|
||||
|
||||
# Routes moved to routes/tags.py blueprint
|
||||
@@ -238,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)
|
||||
@@ -247,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)
|
||||
@@ -257,35 +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'])
|
||||
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'])
|
||||
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'])
|
||||
def delete_exercise(exercise_id):
|
||||
db.exercises.delete_exercise(exercise_id)
|
||||
return ""
|
||||
|
||||
@app.teardown_appcontext
|
||||
def closeConnection(exception):
|
||||
|
||||
190
db.py
190
db.py
@@ -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:
|
||||
@@ -66,22 +70,17 @@ class DataBase():
|
||||
|
||||
|
||||
def get_exercise(self, exercise_id):
|
||||
exercise = self.execute(
|
||||
'SELECT exercise_id, name FROM exercise WHERE exercise_id=%s LIMIT 1', [exercise_id], one=True)
|
||||
return exercise
|
||||
return self.exercises.get_exercise(exercise_id)
|
||||
|
||||
def create_exercise(self, name):
|
||||
new_exercise = self.execute('INSERT INTO exercise (name) VALUES (%s) RETURNING exercise_id AS "ExerciseId"',
|
||||
[name], commit=True, one=True)
|
||||
return new_exercise['ExerciseId']
|
||||
def create_exercise(self, name, attribute_ids=None):
|
||||
return self.exercises.add_exercise(name, attribute_ids)
|
||||
|
||||
def delete_exercise(self, exercise_id):
|
||||
self.execute('DELETE FROM exercise WHERE exercise_id=%s', [
|
||||
exercise_id], commit=True)
|
||||
|
||||
def update_exercise(self, exercise_id, name):
|
||||
self.execute('UPDATE Exercise SET Name=%s WHERE exercise_id=%s', [
|
||||
name, exercise_id], commit=True)
|
||||
def update_exercise(self, exercise_id, name, attribute_ids=None):
|
||||
return self.exercises.update_exercise(exercise_id, name, attribute_ids)
|
||||
|
||||
def get_people(self):
|
||||
people = self.execute(
|
||||
@@ -380,11 +379,31 @@ 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):
|
||||
exercises = self.execute(
|
||||
'SELECT exercise_id, name FROM exercise')
|
||||
return exercises
|
||||
return self.exercises.get("")
|
||||
|
||||
def get_exercise_progress_for_user(self, person_id, exercise_id, min_date=None, max_date=None, epoch='all', degree=1):
|
||||
today = datetime.now()
|
||||
@@ -411,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])
|
||||
@@ -429,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,
|
||||
@@ -442,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
|
||||
|
||||
@@ -466,6 +493,95 @@ class DataBase():
|
||||
|
||||
return result[0]['earliest_date'], result[0]['latest_date']
|
||||
|
||||
def get_topset_achievements(self, topset_id):
|
||||
# 1. Fetch current topset details
|
||||
current = self.execute("""
|
||||
SELECT
|
||||
t.weight, t.repetitions, t.exercise_id, w.person_id, w.start_date, w.workout_id,
|
||||
ROUND((100 * t.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * t.repetitions), 0)::NUMERIC::INTEGER AS estimated_1rm
|
||||
FROM topset t
|
||||
JOIN workout w ON t.workout_id = w.workout_id
|
||||
WHERE t.topset_id = %s
|
||||
""", [topset_id], one=True)
|
||||
|
||||
if not current:
|
||||
return {}
|
||||
|
||||
person_id = current['person_id']
|
||||
exercise_id = current['exercise_id']
|
||||
current_date = current['start_date']
|
||||
current_weight = current['weight']
|
||||
current_reps = current['repetitions']
|
||||
current_e1rm = current['estimated_1rm']
|
||||
|
||||
# 2. Fetch "Last Time" (previous workout's best set for this exercise)
|
||||
last_set = self.execute("""
|
||||
SELECT t.weight, t.repetitions
|
||||
FROM topset t
|
||||
JOIN workout w ON t.workout_id = w.workout_id
|
||||
WHERE w.person_id = %s AND t.exercise_id = %s AND w.start_date < %s
|
||||
ORDER BY w.start_date DESC, (100 * t.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * t.repetitions) DESC
|
||||
LIMIT 1
|
||||
""", [person_id, exercise_id, current_date], one=True)
|
||||
|
||||
# 3. Fetch All-Time Bests (strictly before current workout)
|
||||
best_stats = self.execute("""
|
||||
SELECT
|
||||
MAX(t.weight) as max_weight,
|
||||
MAX(ROUND((100 * t.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * t.repetitions), 0)) as max_e1rm,
|
||||
MAX(t.repetitions) FILTER (WHERE t.weight >= %s) as max_reps_at_weight
|
||||
FROM topset t
|
||||
JOIN workout w ON t.workout_id = w.workout_id
|
||||
WHERE w.person_id = %s AND t.exercise_id = %s AND w.start_date < %s
|
||||
""", [current_weight, person_id, exercise_id, current_date], one=True)
|
||||
|
||||
achievements = {
|
||||
'is_pr_weight': False,
|
||||
'is_pr_e1rm': False,
|
||||
'is_pr_reps': False,
|
||||
'weight_increase': 0,
|
||||
'rep_increase': 0,
|
||||
'stalled_sessions': 0
|
||||
}
|
||||
|
||||
# Calculate PRs
|
||||
if best_stats:
|
||||
if best_stats['max_weight'] and current_weight > best_stats['max_weight']:
|
||||
achievements['is_pr_weight'] = True
|
||||
if best_stats['max_e1rm'] and current_e1rm > best_stats['max_e1rm']:
|
||||
achievements['is_pr_e1rm'] = True
|
||||
if best_stats['max_reps_at_weight'] and current_reps > best_stats['max_reps_at_weight']:
|
||||
achievements['is_pr_reps'] = True
|
||||
|
||||
# Calculate Stalled Sessions
|
||||
# Count consecutive previous workouts for this exercise where weight and reps were identical to current
|
||||
previous_sets = self.execute("""
|
||||
SELECT t.weight, t.repetitions
|
||||
FROM topset t
|
||||
JOIN workout w ON t.workout_id = w.workout_id
|
||||
WHERE w.person_id = %s AND t.exercise_id = %s AND w.start_date < %s
|
||||
ORDER BY w.start_date DESC
|
||||
""", [person_id, exercise_id, current_date])
|
||||
|
||||
stalled_count = 0
|
||||
for s in previous_sets:
|
||||
if s['weight'] == current_weight and s['repetitions'] == current_reps:
|
||||
stalled_count += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if stalled_count >= 1: # If it's the same as at least the previous session
|
||||
achievements['stalled_sessions'] = stalled_count
|
||||
|
||||
# Calculate Increases vs Last Time
|
||||
if last_set:
|
||||
if current_weight > last_set['weight']:
|
||||
achievements['weight_increase'] = current_weight - last_set['weight']
|
||||
elif current_weight == last_set['weight'] and current_reps > last_set['repetitions']:
|
||||
achievements['rep_increase'] = current_reps - last_set['repetitions']
|
||||
|
||||
return achievements
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
138
decorators.py
138
decorators.py
@@ -1,12 +1,49 @@
|
||||
from functools import wraps
|
||||
from flask import render_template, url_for, request
|
||||
from flask_login import current_user
|
||||
|
||||
from flask import render_template, url_for
|
||||
|
||||
def get_params(*args):
|
||||
"""Helper to get parameters from kwargs, form, or args."""
|
||||
res = []
|
||||
for arg in args:
|
||||
val = request.view_args.get(arg)
|
||||
if val is None:
|
||||
val = request.form.get(arg, type=int)
|
||||
if val is None:
|
||||
val = request.args.get(arg, type=int)
|
||||
res.append(val)
|
||||
return res[0] if len(res) == 1 else tuple(res)
|
||||
|
||||
|
||||
def get_person_id_from_context():
|
||||
"""Helper to find person_id from URL/form context."""
|
||||
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
|
||||
|
||||
from app import db
|
||||
if person_id is not None:
|
||||
return person_id
|
||||
|
||||
if workout_id is not None:
|
||||
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
||||
if workout_info:
|
||||
return workout_info['person_id']
|
||||
|
||||
if topset_id is not None:
|
||||
topset_info = db.execute("SELECT workout_id FROM topset WHERE topset_id = %s", [topset_id], one=True)
|
||||
if topset_info:
|
||||
w_id = topset_info['workout_id']
|
||||
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [w_id], one=True)
|
||||
if workout_info:
|
||||
return workout_info['person_id']
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_person(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
person_id = kwargs.get('person_id')
|
||||
person_id = get_params('person_id')
|
||||
from app import db
|
||||
person = db.is_valid_person(person_id)
|
||||
if person is None:
|
||||
@@ -18,12 +55,14 @@ def validate_person(func):
|
||||
def validate_workout(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
person_id = kwargs.get('person_id')
|
||||
workout_id = kwargs.get('workout_id')
|
||||
person_id, workout_id = get_params('person_id', 'workout_id')
|
||||
from app import db
|
||||
if person_id is None and workout_id is not None:
|
||||
person_id = get_person_id_from_context()
|
||||
|
||||
workout = db.is_valid_workout(person_id, workout_id)
|
||||
if workout is None:
|
||||
return render_template('error.html', error='404', message=f'Unable to find Workout({workout_id}) completed by Person({person_id})', url=url_for('person_overview', person_id=person_id))
|
||||
return render_template('error.html', error='404', message=f'Unable to find Workout({workout_id}) completed by Person({person_id})', url=url_for('person_overview', person_id=person_id) if person_id else '/')
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
@@ -31,12 +70,93 @@ def validate_workout(func):
|
||||
def validate_topset(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
person_id = kwargs.get('person_id')
|
||||
workout_id = kwargs.get('workout_id')
|
||||
topset_id = kwargs.get('topset_id')
|
||||
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
|
||||
from app import db
|
||||
if (person_id is None or workout_id is None) and topset_id is not None:
|
||||
person_id = get_person_id_from_context()
|
||||
# We could also find workout_id, but is_valid_topset handles it if we have at least topset_id
|
||||
|
||||
topset = db.is_valid_topset(person_id, workout_id, topset_id)
|
||||
if topset is None:
|
||||
return render_template('error.html', error='404', message=f'Unable to find TopSet({topset_id}) in Workout({workout_id}) completed by Person({person_id})', url=url_for('get_workout', person_id=person_id, workout_id=workout_id))
|
||||
fallback_url = url_for('person_overview', person_id=person_id) if person_id else '/'
|
||||
return render_template('error.html', error='404', message=f'Unable to find TopSet({topset_id})', url=fallback_url)
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
ACTION_MAP = {
|
||||
'workout.create_workout': 'create a workout',
|
||||
'workout.delete_workout': 'delete this workout',
|
||||
'workout.update_workout_start_date': 'change the date for this workout',
|
||||
'workout.create_topset': 'add a set',
|
||||
'workout.update_topset': 'update this set',
|
||||
'workout.delete_topset': 'delete this set',
|
||||
'delete_person': 'delete this person',
|
||||
'update_person_name': 'update this person\'s name',
|
||||
'tags.add_tag': 'add a tag',
|
||||
'tags.delete_tag': 'delete this tag',
|
||||
'tags.add_tag_to_workout': 'add a tag to this workout',
|
||||
'tags.create_new_tag_for_workout': 'create a new tag for this workout',
|
||||
'workout.create_program': 'create a workout program',
|
||||
'programs.delete_program': 'delete this workout program',
|
||||
'delete_exercise': 'delete an exercise',
|
||||
'delete_person': 'delete a user',
|
||||
}
|
||||
|
||||
|
||||
def admin_required(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated or not getattr(current_user, 'is_admin', False):
|
||||
from flask import flash
|
||||
msg = "You must be an admin to perform this action."
|
||||
if request.endpoint in ACTION_MAP:
|
||||
msg = f"You must be an admin to {ACTION_MAP[request.endpoint]}."
|
||||
|
||||
flash(msg, "warning")
|
||||
if request.headers.get('HX-Request'):
|
||||
return '', 200, {'HX-Redirect': url_for('dashboard')}
|
||||
return render_template('error.html', error='403', message=msg, url='/')
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def get_auth_message(endpoint, person_id=None, is_authenticated=False):
|
||||
"""Generates a friendly authorization message."""
|
||||
action = ACTION_MAP.get(endpoint)
|
||||
if not action:
|
||||
# Fallback: prettify endpoint name if not in map
|
||||
# e.g. 'workout.create_topset' -> 'create topset'
|
||||
action = endpoint.split('.')[-1].replace('_', ' ')
|
||||
|
||||
if is_authenticated:
|
||||
msg = f"You are not authorized to {action}"
|
||||
else:
|
||||
msg = f"Please log in to {action}"
|
||||
|
||||
if person_id:
|
||||
from app import db
|
||||
person_name = db.get_person_name(person_id)
|
||||
if person_name:
|
||||
msg += f" for {person_name}"
|
||||
return msg
|
||||
|
||||
|
||||
def require_ownership(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
person_id = get_person_id_from_context()
|
||||
|
||||
# Authorization check: must be logged in and (the owner or an admin)
|
||||
is_admin = getattr(current_user, 'is_admin', False)
|
||||
if not current_user.is_authenticated or (person_id is not None and int(current_user.get_id()) != person_id and not is_admin):
|
||||
from flask import flash
|
||||
msg = get_auth_message(request.endpoint, person_id, is_authenticated=current_user.is_authenticated)
|
||||
flash(msg, "info")
|
||||
|
||||
if request.headers.get('HX-Request'):
|
||||
return '', 200, {'HX-Redirect': url_for('auth.login') if not current_user.is_authenticated else url_for('dashboard')}
|
||||
return render_template('error.html', error='403', message='You are not authorized to modify this resource.', url='/')
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
60
features/activity.py
Normal file
60
features/activity.py
Normal 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)
|
||||
@@ -3,27 +3,182 @@ class Exercises:
|
||||
self.execute = db_connection_method
|
||||
|
||||
def get(self, query):
|
||||
# Add wildcards to the query
|
||||
search_query = f"%{query}%"
|
||||
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'])
|
||||
|
||||
return exercises
|
||||
|
||||
def get_exercise(self, exercise_id):
|
||||
exercise = self.execute("SELECT exercise_id, name FROM exercise WHERE exercise_id=%s;", [exercise_id], one=True)
|
||||
if exercise:
|
||||
exercise['attributes'] = self.get_exercise_attributes(exercise_id)
|
||||
return exercise
|
||||
|
||||
def update_exercise_name(self, exercise_id, updated_name):
|
||||
self.execute("UPDATE exercise SET name = %s WHERE exercise_id = %s;", [updated_name, exercise_id], commit=True)
|
||||
updated_exercise = self.get_exercise(exercise_id)
|
||||
return updated_exercise
|
||||
def get_exercise_attributes(self, exercise_id):
|
||||
query = """
|
||||
SELECT cat.name as category_name, attr.attribute_id, attr.name as attribute_name
|
||||
FROM exercise_to_attribute eta
|
||||
JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
|
||||
JOIN exercise_attribute_category cat ON attr.category_id = cat.category_id
|
||||
WHERE eta.exercise_id = %s
|
||||
ORDER BY cat.name, attr.name
|
||||
"""
|
||||
return self.execute(query, [exercise_id])
|
||||
|
||||
def get_all_attribute_categories(self):
|
||||
return self.execute("SELECT category_id, name FROM exercise_attribute_category ORDER BY name")
|
||||
|
||||
def get_attributes_by_category(self):
|
||||
# Returns a dict: { category_name: [ {id, name}, ... ] }
|
||||
categories = self.get_all_attribute_categories()
|
||||
all_attrs = self.execute("SELECT attribute_id, name, category_id FROM exercise_attribute ORDER BY name")
|
||||
|
||||
result = {}
|
||||
for cat in categories:
|
||||
result[cat['name']] = [a for a in all_attrs if a['category_id'] == cat['category_id']]
|
||||
return result
|
||||
|
||||
def update_exercise(self, exercise_id, name, attribute_ids=None):
|
||||
self.execute("UPDATE exercise SET name = %s WHERE exercise_id = %s;", [name, exercise_id], commit=True)
|
||||
|
||||
# Update attributes: simple delete and re-insert for now
|
||||
self.execute("DELETE FROM exercise_to_attribute WHERE exercise_id = %s", [exercise_id], commit=True)
|
||||
|
||||
if attribute_ids:
|
||||
for attr_id in attribute_ids:
|
||||
if attr_id:
|
||||
self.execute("INSERT INTO exercise_to_attribute (exercise_id, attribute_id) VALUES (%s, %s)",
|
||||
[exercise_id, attr_id], commit=True)
|
||||
|
||||
return self.get_exercise(exercise_id)
|
||||
|
||||
def delete_exercise(self, exercise_id):
|
||||
self.execute('DELETE FROM exercise WHERE exercise_id=%s', [
|
||||
exercise_id], commit=True)
|
||||
|
||||
def add_exercise(self, name):
|
||||
def add_exercise(self, name, attribute_ids=None):
|
||||
result = self.execute('INSERT INTO exercise (name) VALUES (%s) RETURNING exercise_id', [name], commit=True, one=True)
|
||||
exercise_id = result['exercise_id']
|
||||
new_exercise = self.get_exercise(exercise_id)
|
||||
return new_exercise
|
||||
|
||||
if attribute_ids:
|
||||
for attr_id in attribute_ids:
|
||||
if attr_id:
|
||||
self.execute("INSERT INTO exercise_to_attribute (exercise_id, attribute_id) VALUES (%s, %s)",
|
||||
[exercise_id, attr_id], commit=True)
|
||||
|
||||
return self.get_exercise(exercise_id)
|
||||
|
||||
def get_workout_attribute_distribution(self, workout_id, category_name):
|
||||
query = """
|
||||
SELECT attr.name as attribute_name, COUNT(*) as count
|
||||
FROM topset t
|
||||
JOIN exercise e ON t.exercise_id = e.exercise_id
|
||||
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 t.workout_id = %s AND cat.name = %s
|
||||
GROUP BY attr.name
|
||||
ORDER BY count DESC
|
||||
"""
|
||||
distribution = self.execute(query, [workout_id, category_name])
|
||||
|
||||
# Calculate percentages and SVG parameters
|
||||
total_counts = sum(item['count'] for item in distribution)
|
||||
accumulated_percentage = 0
|
||||
|
||||
# Color palette for segments
|
||||
colors = [
|
||||
"#3b82f6", # blue-500
|
||||
"#06b6d4", # cyan-500
|
||||
"#8b5cf6", # violet-500
|
||||
"#ec4899", # pink-500
|
||||
"#f59e0b", # amber-500
|
||||
"#10b981", # emerald-500
|
||||
"#6366f1", # indigo-500
|
||||
"#f43f5e", # rose-500
|
||||
"#84cc16", # lime-500
|
||||
"#0ea5e9", # sky-500
|
||||
]
|
||||
|
||||
if total_counts > 0:
|
||||
for i, item in enumerate(distribution):
|
||||
percentage = (item['count'] / total_counts) * 100
|
||||
item['percentage'] = round(percentage)
|
||||
item['dasharray'] = f"{percentage} 100"
|
||||
item['dashoffset'] = -accumulated_percentage
|
||||
item['color'] = colors[i % len(colors)]
|
||||
accumulated_percentage += percentage
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -70,18 +70,40 @@ class Schema:
|
||||
|
||||
def generate_mermaid_er(self, schema_info):
|
||||
"""Generates Mermaid ER diagram code from schema info."""
|
||||
mermaid_lines = ["erDiagram"]
|
||||
for table, info in schema_info.items():
|
||||
mermaid_lines = [
|
||||
"%%{init: {'theme': 'default', 'themeCSS': '.er.entityBox { fill: transparent !important; } .er.attributeBoxEven { fill: transparent !important; } .er.attributeBoxOdd { fill: transparent !important; }'}}%%",
|
||||
"erDiagram"
|
||||
]
|
||||
|
||||
# Sort tables for stable output
|
||||
sorted_tables = sorted(schema_info.keys())
|
||||
|
||||
for table in sorted_tables:
|
||||
info = schema_info[table]
|
||||
mermaid_lines.append(f" {table} {{")
|
||||
|
||||
pks = set(info.get('primary_keys', []))
|
||||
fks = {fk[0] for fk in info.get('foreign_keys', [])}
|
||||
|
||||
for column_name, data_type in info['columns']:
|
||||
mermaid_data_type = self._map_data_type(data_type)
|
||||
pk_marker = " PK" if column_name in info.get('primary_keys', []) else ""
|
||||
mermaid_lines.append(f" {mermaid_data_type} {column_name}{pk_marker}")
|
||||
|
||||
markers = []
|
||||
if column_name in pks:
|
||||
markers.append("PK")
|
||||
if column_name in fks:
|
||||
markers.append("FK")
|
||||
|
||||
marker_str = f" {','.join(markers)}" if markers else ""
|
||||
mermaid_lines.append(f" {mermaid_data_type} {column_name}{marker_str}")
|
||||
mermaid_lines.append(" }")
|
||||
|
||||
for table, info in schema_info.items():
|
||||
for fk_column, referenced_table, referenced_column in info['foreign_keys']:
|
||||
relation = f" {table} }}|--|| {referenced_table} : \"{fk_column} to {referenced_column}\""
|
||||
for table in sorted_tables:
|
||||
info = schema_info[table]
|
||||
# Sort foreign keys for stable output
|
||||
sorted_fks = sorted(info.get('foreign_keys', []), key=lambda x: x[0])
|
||||
for fk_column, referenced_table, referenced_column in sorted_fks:
|
||||
relation = f" {referenced_table} ||--o{{ {table} : \"{fk_column}\""
|
||||
mermaid_lines.append(relation)
|
||||
return "\n".join(mermaid_lines)
|
||||
|
||||
|
||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from workout!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -1,9 +1,10 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
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
|
||||
from utils import get_client_ip
|
||||
|
||||
auth = Blueprint('auth', __name__)
|
||||
|
||||
@@ -11,11 +12,12 @@ class Person:
|
||||
"""
|
||||
Simple Person class compatible with Flask-Login.
|
||||
"""
|
||||
def __init__(self, person_id, name, email, password_hash):
|
||||
def __init__(self, person_id, name, email, password_hash, is_admin=False):
|
||||
self.id = person_id
|
||||
self.name = name
|
||||
self.email = email
|
||||
self.password_hash = password_hash
|
||||
self.is_admin = is_admin
|
||||
|
||||
def get_id(self):
|
||||
"""Required by Flask-Login to get a unique user identifier."""
|
||||
@@ -43,14 +45,14 @@ def get_person_by_id(person_id):
|
||||
Fetch a person record by person_id and return a Person object.
|
||||
"""
|
||||
sql = """
|
||||
SELECT person_id, name, email, password_hash
|
||||
SELECT person_id, name, email, password_hash, is_admin
|
||||
FROM person
|
||||
WHERE person_id = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
row = db.execute(sql, [person_id], one=True)
|
||||
if row:
|
||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'])
|
||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'], row['is_admin'])
|
||||
return None
|
||||
|
||||
|
||||
@@ -59,14 +61,14 @@ def get_person_by_email(email):
|
||||
Fetch a person record by email and return a Person object.
|
||||
"""
|
||||
sql = """
|
||||
SELECT person_id, name, email, password_hash
|
||||
SELECT person_id, name, email, password_hash, is_admin
|
||||
FROM person
|
||||
WHERE email = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
row = db.execute(sql, [email], one=True)
|
||||
if row:
|
||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'])
|
||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'], row['is_admin'])
|
||||
return None
|
||||
|
||||
|
||||
@@ -83,6 +85,17 @@ def create_person(name, email, password_hash):
|
||||
return row['person_id']
|
||||
|
||||
|
||||
def record_login_attempt(email, success, person_id=None):
|
||||
"""
|
||||
Record a login attempt in the database.
|
||||
"""
|
||||
sql = """
|
||||
INSERT INTO login_attempts (email, ip_address, success, user_agent, person_id)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
db.execute(sql, [email, get_client_ip(), success, request.user_agent.string, person_id], commit=True)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Blueprint endpoints
|
||||
# ---------------------
|
||||
@@ -92,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)
|
||||
@@ -108,10 +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)
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -119,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'))
|
||||
|
||||
@@ -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
184
routes/exercises.py
Normal 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 ""
|
||||
@@ -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'])
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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 # Add if authentication is needed
|
||||
from jinja2_fragments import render_block # Import render_block
|
||||
from flask_login import login_required, current_user
|
||||
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 # Uncomment if login is required
|
||||
@login_required
|
||||
def create_program():
|
||||
if request.method == 'POST':
|
||||
program_name = request.form.get('program_name', '').strip()
|
||||
@@ -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 # Add authentication if needed
|
||||
@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
68
routes/settings.py
Normal 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)
|
||||
@@ -2,10 +2,11 @@ import os
|
||||
import requests # Import requests library
|
||||
import json # Import json library
|
||||
from flask import Blueprint, render_template, request, current_app, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from jinja2_fragments import render_block
|
||||
from flask_htmx import HTMX
|
||||
from extensions import db
|
||||
from utils import prepare_svg_plot_data # Will be created for SVG data prep
|
||||
from utils import prepare_svg_plot_data, get_client_ip # Will be created for SVG data prep
|
||||
|
||||
sql_explorer_bp = Blueprint('sql_explorer', __name__, url_prefix='/sql')
|
||||
htmx = HTMX()
|
||||
@@ -13,6 +14,32 @@ htmx = HTMX()
|
||||
|
||||
|
||||
|
||||
def record_sql_audit(query, success, error_message=None):
|
||||
"""Records a SQL execution in the audit table."""
|
||||
try:
|
||||
person_id = getattr(current_user, 'id', None)
|
||||
ip_address = get_client_ip()
|
||||
sql = """
|
||||
INSERT INTO sql_audit (person_id, query, ip_address, success, error_message)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
db.execute(sql, [person_id, query, ip_address, success, error_message], commit=True)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to record SQL audit: {e}")
|
||||
|
||||
def record_llm_audit(prompt, response, model, success, error_message=None):
|
||||
"""Records an LLM interaction in the audit table."""
|
||||
try:
|
||||
person_id = getattr(current_user, 'id', None)
|
||||
ip_address = get_client_ip()
|
||||
sql = """
|
||||
INSERT INTO llm_audit (person_id, prompt, response, model, ip_address, success, error_message)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
db.execute(sql, [person_id, prompt, response, model, ip_address, success, error_message], commit=True)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to record LLM audit: {e}")
|
||||
|
||||
def _execute_sql(query):
|
||||
"""Executes arbitrary SQL query, returning results, columns, and error."""
|
||||
results, columns, error = None, [], None
|
||||
@@ -20,9 +47,11 @@ def _execute_sql(query):
|
||||
results = db.execute(query)
|
||||
if results:
|
||||
columns = list(results[0].keys()) if isinstance(results, list) and results else []
|
||||
record_sql_audit(query, True)
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
db.getDB().rollback()
|
||||
record_sql_audit(query, False, error)
|
||||
return (results, columns, error)
|
||||
|
||||
def _save_query(title, query):
|
||||
@@ -51,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."
|
||||
@@ -60,10 +89,11 @@ def _generate_sql_from_natural_language(natural_query):
|
||||
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gemni_model}:generateContent?key={api_key}"
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
prompt = natural_query
|
||||
try:
|
||||
# Get and format schema
|
||||
schema_info = _get_schema_info()
|
||||
schema_string = _generate_create_script(schema_info)
|
||||
schema_info = db.schema.get_schema_info()
|
||||
schema_string = db.schema.generate_create_script(schema_info)
|
||||
|
||||
prompt = f"""Given the following database schema:
|
||||
```sql
|
||||
@@ -112,14 +142,20 @@ Return ONLY the SQL query, without any explanation or surrounding text/markdown.
|
||||
filtered_lines = [line for line in sql_lines if not line.strip().startswith('--')]
|
||||
final_sql = "\n".join(filtered_lines).strip()
|
||||
|
||||
return final_sql, None
|
||||
generated_sql, error = final_sql, None
|
||||
record_llm_audit(prompt, generated_sql, gemni_model, True)
|
||||
return generated_sql, error
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
current_app.logger.error(f"Gemini API request error: {e}")
|
||||
return None, f"Error communicating with API: {e}"
|
||||
error_msg = f"Error communicating with API: {e}"
|
||||
record_llm_audit(prompt, None, gemni_model, False, error_msg)
|
||||
return None, error_msg
|
||||
except (KeyError, IndexError, Exception) as e:
|
||||
current_app.logger.error(f"Error processing Gemini API response: {e} - Response: {response_data if 'response_data' in locals() else 'N/A'}")
|
||||
return None, f"Error processing API response: {e}"
|
||||
error_msg = f"Error processing API response: {e}"
|
||||
record_llm_audit(prompt, None, gemni_model, False, error_msg)
|
||||
return None, error_msg
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
@@ -132,6 +168,7 @@ def sql_explorer():
|
||||
return render_template('sql_explorer.html', saved_queries=saved_queries)
|
||||
|
||||
@sql_explorer_bp.route("/query", methods=['POST'])
|
||||
@login_required
|
||||
def sql_query():
|
||||
query = request.form.get('query')
|
||||
title = request.form.get('title')
|
||||
@@ -141,6 +178,7 @@ def sql_query():
|
||||
title=title, query=query, error=error, saved_queries=saved_queries)
|
||||
|
||||
@sql_explorer_bp.route("/query/execute", methods=['POST'])
|
||||
@login_required
|
||||
def execute_sql_query():
|
||||
query = request.form.get('query')
|
||||
(results, columns, error) = _execute_sql(query)
|
||||
@@ -155,6 +193,7 @@ def load_sql_query(query_id):
|
||||
title=title, query=query, saved_queries=saved_queries)
|
||||
|
||||
@sql_explorer_bp.route('/delete_query/<int:query_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_sql_query(query_id):
|
||||
_delete_saved_query(query_id)
|
||||
saved_queries = _list_saved_queries()
|
||||
@@ -168,6 +207,7 @@ def sql_schema():
|
||||
return render_template('partials/sql_explorer/schema.html', create_sql=create_sql)
|
||||
|
||||
@sql_explorer_bp.route("/plot/<int:query_id>", methods=['GET'])
|
||||
@login_required
|
||||
def plot_query(query_id):
|
||||
(title, query) = _get_saved_query(query_id)
|
||||
if not query: return "Query not found", 404
|
||||
@@ -191,6 +231,7 @@ def plot_query(query_id):
|
||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
||||
|
||||
@sql_explorer_bp.route("/plot/show", methods=['POST'])
|
||||
@login_required
|
||||
def plot_unsaved_query():
|
||||
query = request.form.get('query')
|
||||
title = request.form.get('title', 'SQL Query Plot') # Add default title
|
||||
@@ -214,6 +255,7 @@ def plot_unsaved_query():
|
||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
||||
|
||||
@sql_explorer_bp.route("/generate_sql", methods=['POST'])
|
||||
@login_required
|
||||
def generate_sql():
|
||||
"""Generates SQL from natural language via Gemini REST API."""
|
||||
natural_query = request.form.get('natural_query')
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from flask import Blueprint, request, redirect, url_for, render_template, current_app
|
||||
from urllib.parse import urlencode, parse_qs, unquote_plus
|
||||
from flask_login import current_user
|
||||
from flask_login import current_user, login_required
|
||||
from extensions import db
|
||||
from jinja2_fragments import render_block
|
||||
from decorators import validate_person, validate_workout, require_ownership
|
||||
|
||||
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
|
||||
|
||||
@@ -54,6 +55,8 @@ def goto_tag():
|
||||
|
||||
|
||||
@tags_bp.route("/add", methods=['POST']) # Changed to POST
|
||||
@login_required
|
||||
@require_ownership
|
||||
def add_tag():
|
||||
"""Adds a tag and returns the updated tags partial."""
|
||||
person_id = request.form.get("person_id") # Get from form data
|
||||
@@ -85,6 +88,8 @@ def add_tag():
|
||||
|
||||
|
||||
@tags_bp.route("/<int:tag_id>/delete", methods=['DELETE']) # Changed to DELETE
|
||||
@login_required
|
||||
@require_ownership
|
||||
def delete_tag(tag_id):
|
||||
"""Deletes a tag and returns the updated tags partial."""
|
||||
# We might get person_id from request body/headers if needed, or assume context
|
||||
@@ -105,6 +110,9 @@ def delete_tag(tag_id):
|
||||
# --- Workout Specific Tag Routes ---
|
||||
|
||||
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
|
||||
@login_required
|
||||
@validate_workout
|
||||
@require_ownership
|
||||
def add_tag_to_workout(workout_id):
|
||||
"""Adds existing tags to a specific workout."""
|
||||
# Note: Authorization (checking if the current user can modify this workout) might be needed here.
|
||||
@@ -181,6 +189,9 @@ def add_tag_to_workout(workout_id):
|
||||
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
|
||||
|
||||
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
|
||||
@login_required
|
||||
@validate_workout
|
||||
@require_ownership
|
||||
def create_new_tag_for_workout(workout_id):
|
||||
"""Creates a new tag and associates it with a specific workout."""
|
||||
# Note: Authorization might be needed here.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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, current_user
|
||||
from extensions import db
|
||||
from decorators import validate_workout, validate_topset
|
||||
from decorators import validate_workout, validate_topset, require_ownership, validate_person
|
||||
from utils import convert_str_to_date
|
||||
from collections import defaultdict # Import defaultdict
|
||||
|
||||
@@ -123,14 +124,23 @@ def _get_workout_view_model(person_id, workout_id):
|
||||
# Sort tags alphabetically by name for consistent display
|
||||
workout_data["tags"].sort(key=lambda x: x.get('tag_name', ''))
|
||||
|
||||
# Add multi-category breakdowns
|
||||
workout_data["muscle_distribution"] = db.exercises.get_workout_attribute_distribution(workout_id, 'Muscle Group')
|
||||
workout_data["equipment_distribution"] = db.exercises.get_workout_attribute_distribution(workout_id, 'Machine vs Free Weight')
|
||||
workout_data["movement_distribution"] = db.exercises.get_workout_attribute_distribution(workout_id, 'Compound vs Isolation')
|
||||
|
||||
return workout_data
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout", methods=['POST'])
|
||||
@login_required
|
||||
@validate_person
|
||||
@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
|
||||
@@ -139,13 +149,18 @@ def create_workout(person_id):
|
||||
return render_block(current_app.jinja_env, 'workout.html', 'content', **view_model)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/delete", methods=['GET'])
|
||||
@login_required
|
||||
@validate_workout
|
||||
@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'])
|
||||
@login_required
|
||||
@validate_workout
|
||||
@require_ownership
|
||||
def get_workout_start_date_edit_form(person_id, workout_id):
|
||||
# Fetch only the necessary data (start_date)
|
||||
workout = db.execute("SELECT start_date FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
||||
@@ -153,10 +168,13 @@ def get_workout_start_date_edit_form(person_id, workout_id):
|
||||
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date, is_edit=True)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date", methods=['PUT'])
|
||||
@login_required
|
||||
@validate_workout
|
||||
@require_ownership
|
||||
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)
|
||||
@@ -169,6 +187,22 @@ def get_workout_start_date(person_id, workout_id):
|
||||
start_date = workout.get('start_date') if workout else None
|
||||
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/distribution", methods=['GET'])
|
||||
def get_workout_distribution(person_id, workout_id):
|
||||
category = request.args.get('category', 'Muscle Group')
|
||||
distribution = db.exercises.get_workout_attribute_distribution(workout_id, category)
|
||||
return render_template('partials/workout_breakdown.html',
|
||||
person_id=person_id,
|
||||
workout_id=workout_id,
|
||||
distribution=distribution,
|
||||
category_name=category)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/achievements", methods=['GET'])
|
||||
@validate_topset
|
||||
def get_topset_achievements(person_id, workout_id, topset_id):
|
||||
achievements = db.get_topset_achievements(topset_id)
|
||||
return render_template('partials/achievement_badges.html', achievements=achievements)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['GET'])
|
||||
@validate_topset
|
||||
def get_topset(person_id, workout_id, topset_id):
|
||||
@@ -176,36 +210,77 @@ def get_topset(person_id, workout_id, topset_id):
|
||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'))
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/edit_form", methods=['GET'])
|
||||
@login_required
|
||||
@validate_topset
|
||||
@require_ownership
|
||||
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
|
||||
@validate_workout
|
||||
@require_ownership
|
||||
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'])
|
||||
@login_required
|
||||
@validate_workout
|
||||
@require_ownership
|
||||
def update_topset(person_id, workout_id, topset_id):
|
||||
exercise_id = request.form.get("exercise_id")
|
||||
repetitions = request.form.get("repetitions")
|
||||
weight = request.form.get("weight")
|
||||
db.update_topset(exercise_id, repetitions, weight, topset_id)
|
||||
exercise = db.get_exercise(exercise_id)
|
||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight)
|
||||
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'])
|
||||
@login_required
|
||||
@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'])
|
||||
@@ -221,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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
python-3.9.18
|
||||
61
scripts/generate_hash.py
Normal file
61
scripts/generate_hash.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
from urllib.parse import urlparse
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python scripts/generate_hash.py <person_id> <new_password>")
|
||||
sys.exit(1)
|
||||
|
||||
person_id = sys.argv[1]
|
||||
password = sys.argv[2]
|
||||
|
||||
db_url_str = os.environ.get('DATABASE_URL')
|
||||
if not db_url_str:
|
||||
print("Error: DATABASE_URL environment variable not set.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Generate hash
|
||||
hashed = generate_password_hash(password)
|
||||
|
||||
# Connect to DB
|
||||
db_url = urlparse(db_url_str)
|
||||
conn = psycopg2.connect(
|
||||
database=db_url.path[1:],
|
||||
user=db_url.username,
|
||||
password=db_url.password,
|
||||
host=db_url.hostname,
|
||||
port=db_url.port
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Verify person exists
|
||||
cur.execute("SELECT name FROM person WHERE person_id = %s", (person_id,))
|
||||
person = cur.fetchone()
|
||||
if not person:
|
||||
print(f"Error: No person found with ID {person_id}")
|
||||
sys.exit(1)
|
||||
|
||||
person_name = person[0]
|
||||
|
||||
# Update password
|
||||
cur.execute(
|
||||
"UPDATE person SET password_hash = %s WHERE person_id = %s",
|
||||
(hashed, person_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
print(f"\nSuccessfully updated password for {person_name} (ID: {person_id})")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -66,7 +66,7 @@ def main():
|
||||
|
||||
# Run mmdc
|
||||
subprocess.run(
|
||||
["bun", "x", "mmdc", "-i", input_file, "-o", target_file],
|
||||
["bun", "x", "mmdc", "-i", input_file, "-o", target_file, "-b", "transparent"],
|
||||
check=True
|
||||
)
|
||||
print(f"Successfully generated {target_file}")
|
||||
|
||||
43
scripts/run_migration.py
Normal file
43
scripts/run_migration.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Database migration runner
|
||||
Execute SQL migration files using the Flask app's database connection
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add parent directory to path to import app
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from extensions import db as database
|
||||
|
||||
def run_migration(migration_file):
|
||||
"""Execute a SQL migration file"""
|
||||
try:
|
||||
# Read migration file
|
||||
migration_path = os.path.join('migrations', migration_file)
|
||||
if not os.path.exists(migration_path):
|
||||
print(f"ERROR: Migration file not found: {migration_path}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(migration_path, 'r') as f:
|
||||
sql = f.read()
|
||||
|
||||
# Execute migration using the database connection
|
||||
database.execute(sql, commit=True)
|
||||
print(f"✓ Successfully executed migration: {migration_file}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python run_migration.py <migration_file>")
|
||||
print("Example: python run_migration.py 003_create_login_history.sql")
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize the app to set up database connection
|
||||
from app import app
|
||||
with app.app_context():
|
||||
migration_file = sys.argv[1]
|
||||
run_migration(migration_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
63
static/css/tailwind.min.css
vendored
63
static/css/tailwind.min.css
vendored
File diff suppressed because one or more lines are too long
12
static/css/tw-elements.min.css
vendored
12
static/css/tw-elements.min.css
vendored
File diff suppressed because one or more lines are too long
BIN
static/img/logo.png
Normal file
BIN
static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 272 KiB |
2029
static/js/mermaid.min.js
vendored
2029
static/js/mermaid.min.js
vendored
File diff suppressed because one or more lines are too long
8
static/js/plotly-2.35.2.min.js
vendored
8
static/js/plotly-2.35.2.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
18
static/js/tw-elements.min.js
vendored
18
static/js/tw-elements.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -6,17 +6,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Workout Tracker</title>
|
||||
<link rel="icon" type="image/svg+xml"
|
||||
href='data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="currentColor"><title>Workout Favicon</title><g><rect x="20" y="28" width="24" height="8" rx="4"/><circle cx="16" cy="32" r="8"/><circle cx="48" cy="32" r="8"/></g></svg>'>
|
||||
<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">
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/logo.png') }}">
|
||||
|
||||
<!-- 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>
|
||||
@@ -40,20 +40,21 @@
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="#" class="text-xl font-bold flex items-center lg:ml-2.5">
|
||||
<img src="https://demo.themesberg.com/windster/images/logo.svg" class="h-6 mr-2"
|
||||
alt="Windster Logo">
|
||||
<a href="/" class="text-xl font-bold flex items-center lg:ml-2.5">
|
||||
<img src="{{ url_for('static', filename='img/logo.png') }}" class="h-8 mr-2"
|
||||
alt="Workout Tracker Logo">
|
||||
<span class="self-center whitespace-nowrap">Workout Tracker</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Show logged-in user's name and Logout link -->
|
||||
<span class="text-slate-700">
|
||||
{{ current_user.name }}
|
||||
</span>
|
||||
<!-- Show logged-in user's name as a link to logout on mobile -->
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="text-slate-400 hover:text-slate-500 flex items-center gap-1">
|
||||
class="text-slate-700 hover:text-slate-900 transition-colors">
|
||||
{{ current_user.name }}
|
||||
</a>
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="text-slate-400 hover:text-slate-500 hidden sm:flex items-center gap-1 transition-colors">
|
||||
<!-- Heroicon: Arrow Left On Rectangle (Logout) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
@@ -93,9 +94,9 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="https://github.com/GabePope/WorkoutTracker"
|
||||
class="ml-6 block text-slate-400 hover:text-slate-500 dark:hover:text-slate-300"><span
|
||||
class="sr-only">Workout Tracker on GitHub</span><svg viewBox="0 0 16 16" class="w-6 h-6"
|
||||
fill="black" aria-hidden="true">
|
||||
class="ml-2 sm:ml-6 hidden sm:block text-slate-400 hover:text-slate-500 dark:hover:text-slate-300">
|
||||
<span class="sr-only">Workout Tracker on GitHub</span>
|
||||
<svg viewBox="0 0 16 16" class="w-6 h-6" fill="black" aria-hidden="true">
|
||||
<path
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z">
|
||||
</path>
|
||||
@@ -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"
|
||||
@@ -240,7 +241,36 @@
|
||||
|
||||
<div class="absolute top-16 right-4 m-4">
|
||||
<div class="bg-white rounded shadow-md w-64" id="notifications-container">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800"
|
||||
role="alert" _="init wait 5s then remove me end on click remove me">
|
||||
<div
|
||||
class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8
|
||||
{% if category == 'success' %}text-green-500 bg-green-100{% elif category == 'danger' %}text-red-500 bg-red-100{% else %}text-blue-500 bg-blue-100{% endif %} rounded-lg">
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor"
|
||||
viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">{{ message }}</div>
|
||||
<button type="button"
|
||||
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8"
|
||||
_="on click remove the closest .flex">
|
||||
<span class="sr-only">Close</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
<template id="notification-template">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
35
templates/partials/achievement_badges.html
Normal file
35
templates/partials/achievement_badges.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% if achievements %}
|
||||
{% if achievements.is_pr_weight or achievements.is_pr_e1rm or achievements.is_pr_reps %}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gradient-to-r from-yellow-100 to-amber-200 px-2.5 py-0.5 text-xs font-bold text-amber-900 shadow-sm ring-1 ring-inset ring-amber-500/30 whitespace-nowrap"
|
||||
title="Personal Record">
|
||||
<svg class="mr-1 h-3 w-3 text-amber-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
PR
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if achievements.weight_increase > 0 %}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-bold text-green-800 shadow-sm ring-1 ring-inset ring-green-500/30 whitespace-nowrap"
|
||||
title="Weight increase vs last time">
|
||||
+{{ achievements.weight_increase }}kg
|
||||
</span>
|
||||
{% elif achievements.rep_increase > 0 %}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-bold text-blue-800 shadow-sm ring-1 ring-inset ring-blue-500/30 whitespace-nowrap"
|
||||
title="Rep increase at same weight vs last time">
|
||||
+{{ achievements.rep_increase }} reps
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if achievements.stalled_sessions >= 1 %}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-600 shadow-sm ring-1 ring-inset ring-slate-400/20 whitespace-nowrap"
|
||||
title="Weight and reps matched for {{ achievements.stalled_sessions + 1 }} sessions total">
|
||||
Stalled ({{ achievements.stalled_sessions + 1 }}x)
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
74
templates/partials/activity_logs.html
Normal file
74
templates/partials/activity_logs.html
Normal 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 %}
|
||||
@@ -1,59 +1,99 @@
|
||||
<tr>
|
||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
||||
<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 whitespace-nowrap text-sm font-semibold text-gray-900 float-right">
|
||||
<td class="p-4 text-sm text-gray-900 hidden sm:table-cell">
|
||||
{% 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>
|
||||
<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-semibold bg-cyan-50 text-cyan-700 border border-cyan-100"
|
||||
title="{{ attr.category_name }}">
|
||||
{{ attr.attribute_name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{% for cat_name, options in all_attributes.items() %}
|
||||
<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,
|
||||
multiple=true,
|
||||
search=true,
|
||||
placeholder='Select ' ~ cat_name
|
||||
)}}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<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>
|
||||
45
templates/partials/exercise/attribute_admin.html
Normal file
45
templates/partials/exercise/attribute_admin.html
Normal 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>
|
||||
76
templates/partials/exercise/category_admin.html
Normal file
76
templates/partials/exercise/category_admin.html
Normal 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>
|
||||
@@ -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()">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
84
templates/partials/exercise_history.html
Normal file
84
templates/partials/exercise_history.html
Normal 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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
31
templates/partials/settings/activity.html
Normal file
31
templates/partials/settings/activity.html
Normal 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>
|
||||
163
templates/partials/settings/exercises.html
Normal file
163
templates/partials/settings/exercises.html
Normal 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>
|
||||
48
templates/partials/settings/export.html
Normal file
48
templates/partials/settings/export.html
Normal 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>
|
||||
87
templates/partials/settings/people.html
Normal file
87
templates/partials/settings/people.html
Normal 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>
|
||||
17
templates/partials/skeleton_graph.html
Normal file
17
templates/partials/skeleton_graph.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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-target="#query" hx-swap="innerHTML"
|
||||
hx-indicator="#sql-spinner"
|
||||
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>
|
||||
@@ -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">
|
||||
@@ -31,9 +45,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
||||
<td class="p-4 text-sm font-semibold text-gray-900">
|
||||
{% if is_edit|default(false, true) == false %}
|
||||
{{ repetitions }} x {{ weight }}kg
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="whitespace-nowrap">{{ repetitions }} x {{ weight }}kg</span>
|
||||
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=workout_id, topset_id=topset_id) }}"
|
||||
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex flex-wrap items-center gap-1">
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center flex-col sm:flex-row">
|
||||
<input type="number"
|
||||
@@ -98,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 ' '
|
||||
@@ -110,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">
|
||||
@@ -124,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>
|
||||
64
templates/partials/workout_breakdown.html
Normal file
64
templates/partials/workout_breakdown.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% set distribution = distribution or muscle_distribution %}
|
||||
{% set category_name = category_name or 'Muscle Group' %}
|
||||
{% set breakdown_id = category_name.lower().replace(' ', '-') %}
|
||||
|
||||
{% if distribution %}
|
||||
<div class="px-4 py-3 bg-white relative" id="{{ breakdown_id }}-breakdown"
|
||||
hx-get="{{ url_for('workout.get_workout_distribution', person_id=person_id, workout_id=workout_id, category=category_name) }}"
|
||||
hx-trigger="topsetAdded from:body" hx-swap="outerHTML">
|
||||
|
||||
<!-- Shared Popover Container (managed by Hyperscript) -->
|
||||
<div id="popover-{{ breakdown_id }}"
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 hidden bg-white border border-gray-200 shadow-2xl rounded-xl p-4 min-w-[200px] animate-in fade-in zoom-in duration-200"
|
||||
_="on click from elsewhere add .hidden to me">
|
||||
<div id="content-{{ breakdown_id }}"></div>
|
||||
<button class="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
|
||||
_="on click add .hidden to #popover-{{ breakdown_id }}">
|
||||
<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="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-[9px] font-black text-gray-400 uppercase tracking-[0.25em]">{{ category_name }} Distribution
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Sleek Performance Bar -->
|
||||
<div class="w-full h-8 flex overflow-hidden rounded-lg bg-gray-100 shadow-inner p-0.5 cursor-pointer">
|
||||
{% for item in distribution %}
|
||||
<div class="h-full transition-all duration-700 ease-in-out flex items-center justify-center relative group first:rounded-l-md last:rounded-r-md"
|
||||
style="width: {{ item.percentage }}%; background-color: {{ item.color }};" _="on click
|
||||
halt the event
|
||||
put '<div class=\'flex flex-col gap-1\'>
|
||||
<div class=\'flex items-center gap-2\'>
|
||||
<div class=\'w-3 h-3 rounded-full\' style=\'background-color: {{ item.color }}\'></div>
|
||||
<span class=\'font-black text-sm uppercase\'>{{ item.attribute_name }}</span>
|
||||
</div>
|
||||
<div class=\'text-2xl font-black text-gray-900\'>{{ item.percentage }}%</div>
|
||||
<div class=\'text-[10px] font-bold text-gray-400 uppercase tracking-wider\'>{{ item.count }} Sets Targeted</div>
|
||||
</div>' into #content-{{ breakdown_id }}
|
||||
remove .hidden from #popover-{{ breakdown_id }}"
|
||||
title="{{ item.attribute_name }}: {{ item.percentage }}%">
|
||||
|
||||
<!-- Labels (Name & Percentage grouped and centered) -->
|
||||
<div
|
||||
class="flex items-center justify-center gap-1.5 leading-none text-white pointer-events-none px-1 overflow-hidden">
|
||||
{% if item.percentage > 18 %}
|
||||
<span class="text-[9px] font-black uppercase tracking-tighter truncate drop-shadow-sm">{{
|
||||
item.attribute_name }}</span>
|
||||
<span class="text-[9px] font-bold opacity-90 whitespace-nowrap drop-shadow-sm">{{ item.percentage
|
||||
}}%</span>
|
||||
{% elif item.percentage > 8 %}
|
||||
<span class="text-[9px] font-black drop-shadow-sm">{{ item.percentage }}%</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Hover State -->
|
||||
<div class="absolute inset-0 bg-white/15 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
29
templates/partials/workout_rows.html
Normal file
29
templates/partials/workout_rows.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
334
templates/program_edit.html
Normal 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 %}
|
||||
89
templates/program_import.html
Normal file
89
templates/program_import.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,219 +2,115 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mt-4 w-full grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Users</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 w-full h-full relative">
|
||||
<!-- Hidden Radio Buttons for CSS Tabs -->
|
||||
<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">
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-hidden rounded-lg max-h-96">
|
||||
<div class="align-middle inline-block min-w-full">
|
||||
<div class="shadow overflow-x-hidden sm:rounded-lg max-h-96 overflow-y-auto overflow-x-hidden">
|
||||
<table class="table-fixed 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-3/5">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div class="relative">
|
||||
<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 dark: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"
|
||||
data-darkreader-inline-stroke=""
|
||||
style="--darkreader-inline-stroke: currentColor;"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="people-search"
|
||||
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
|
||||
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" 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-3" 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-wrap -mx-3 mb-2">
|
||||
<div class="grow px-3">
|
||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
|
||||
New user
|
||||
</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"
|
||||
type="text" name="name">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row pt-6 px-3 w-36">
|
||||
<button
|
||||
class="w-full flex 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 text-center items-center h-12"
|
||||
type="submit">
|
||||
<svg class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
||||
fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
||||
data-darkreader-inline-fill="" style="--darkreader-inline-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"></path>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- 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" 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"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<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"></path>
|
||||
</svg>
|
||||
Users
|
||||
</label>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<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"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Exercises
|
||||
</label>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<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"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
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>
|
||||
|
||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Exercises</h3>
|
||||
<!-- Users Tab Content -->
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="rounded-lg">
|
||||
<div class="align-middle inline-block min-w-full max-h-96 overflow-y-auto overflow-x-hidden">
|
||||
<div class="shadow overflow-hidden sm:rounded-lg ">
|
||||
<table class="table-fixed 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-3/4">
|
||||
<div class="relative">
|
||||
<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 dark: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"
|
||||
data-darkreader-inline-stroke=""
|
||||
style="--darkreader-inline-stroke: currentColor;"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="exercise-search"
|
||||
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
|
||||
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" 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)}}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="w-full mt-8" 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="flex flex-wrap -mx-3 mb-2">
|
||||
<div class="grow px-3">
|
||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
|
||||
New exercise
|
||||
</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"
|
||||
type="text" name="name">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row pt-6 px-3 w-36">
|
||||
<button
|
||||
class="w-full flex 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 text-center items-center h-12 cursor-pointer"
|
||||
type="submit">
|
||||
<svg class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
||||
fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
||||
data-darkreader-inline-fill="" style="--darkreader-inline-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"></path>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Data Export Section -->
|
||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Data Export</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4"> <!-- Added space-y-4 for spacing between buttons -->
|
||||
<p class="text-sm text-gray-600">Download all workout set data as a CSV file, or the entire database
|
||||
structure and data as an SQL script.</p>
|
||||
<a href="{{ url_for('export.export_workouts_csv') }}" class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium
|
||||
rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
|
||||
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v3.586l-1.293-1.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V8z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Export All Workouts (CSV)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_database_sql') }}"
|
||||
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
|
||||
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg> <!-- Using a generic download/database icon -->
|
||||
Export Database (SQL Script)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exercises Tab Content -->
|
||||
<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" 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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -45,105 +45,103 @@
|
||||
<div class='p-0 md:p-4 m-0 md:m-2'>
|
||||
<div class="relative w-full h-full">
|
||||
<!-- Modal content -->
|
||||
<div class="relative bg-white rounded-lg shadow">
|
||||
<div class="relative bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-start justify-between p-2 md:p-4 border-0 md:border-b rounded-t">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-full">
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ person_name }}</h3>
|
||||
<div class="p-4 md:p-6 border-b relative">
|
||||
<div class="flex flex-col w-full pr-8">
|
||||
<h3 class="text-2xl font-black text-gray-900 leading-tight">{{ person_name }}</h3>
|
||||
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id,
|
||||
tags=tags) }}
|
||||
|
||||
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id,
|
||||
tags=tags) }}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||
{{ render_partial('partials/start_date.html', person_id=person_id,
|
||||
workout_id=workout_id,
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
{{ render_partial('partials/start_date.html', person_id=person_id, workout_id=workout_id,
|
||||
start_date=start_date) }}
|
||||
|
||||
|
||||
{{ render_partial('partials/workout_note.html', person_id=person_id,
|
||||
workout_id=workout_id,
|
||||
{{ render_partial('partials/workout_note.html', person_id=person_id, workout_id=workout_id,
|
||||
note=note) }}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="absolute right-0 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white mr-2"
|
||||
class="absolute top-4 right-4 text-gray-400 bg-transparent hover:bg-gray-100 hover:text-gray-900 rounded-lg text-sm p-2 transition-colors inline-flex items-center"
|
||||
hx-get="{{ url_for('workout.delete_workout', person_id=person_id, workout_id=workout_id) }}"
|
||||
hx-confirm="Are you sure you wish to delete this workout?" hx-push-url="true"
|
||||
hx-target="#container">
|
||||
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg" data-darkreader-inline-fill=""
|
||||
style="--darkreader-inline-fill:currentColor;">
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
<span class="sr-only">Delete workout</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Multi-Category Breakdown Stripes -->
|
||||
<div class="bg-gray-50/50 border-b divide-y divide-gray-100">
|
||||
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
|
||||
distribution=muscle_distribution, category_name='Muscle Group') }}
|
||||
|
||||
<div class="relative bg-white rounded-lg shadow mt-4">
|
||||
<div class="p-2 md:p-4 border-0 md:border-b rounded-t">
|
||||
<!-- Modal footer -->
|
||||
<div class="flex items-center p-1 md:p-2 rounded-b dark:border-gray-600">
|
||||
{{ render_partial('partials/new_set_form.html', person_id=person_id,
|
||||
workout_id=workout_id,
|
||||
exercises=exercises,
|
||||
has_value=False) }}
|
||||
</div>
|
||||
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
|
||||
distribution=equipment_distribution, category_name='Machine vs Free Weight') }}
|
||||
|
||||
<!-- Modal body -->
|
||||
<div class="p-3 md:p-6 space-y-6">
|
||||
<table class="items-center w-full bg-transparent border-collapse table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
|
||||
Exercise</th>
|
||||
<th
|
||||
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
|
||||
Top Set</th>
|
||||
<th
|
||||
class="bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap w-9 sm:w-20">
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100" id="new-workout" hx-target="closest tr"
|
||||
hx-swap="outerHTML swap:0.5s">
|
||||
{% for top_set in top_sets %}
|
||||
{{ render_partial('partials/topset.html', person_id=person_id,
|
||||
workout_id=workout_id, **top_set) }}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
|
||||
distribution=movement_distribution, category_name='Compound vs Isolation') }}
|
||||
</div>
|
||||
|
||||
{% if top_sets|length == 0 %}
|
||||
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert"
|
||||
id="no-workouts">
|
||||
No top_sets found.
|
||||
<!-- Workout Content Card -->
|
||||
<div class="p-0">
|
||||
<div class="p-2 md:p-4 border-0 md:border-b">
|
||||
<!-- Modal footer / New Set Form -->
|
||||
<div class="flex items-center p-1 md:p-2 rounded-b">
|
||||
{{ render_partial('partials/new_set_form.html', person_id=person_id,
|
||||
workout_id=workout_id,
|
||||
exercises=exercises,
|
||||
has_value=False) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="exercise-progress" class="mx-0 md:mx-5">
|
||||
</div>
|
||||
<!-- Modal body / Top Sets Table -->
|
||||
<div class="p-3 md:p-6 space-y-6">
|
||||
<table class="items-center w-full bg-transparent border-collapse table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
|
||||
Exercise</th>
|
||||
<th
|
||||
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
|
||||
Top Set</th>
|
||||
<th
|
||||
class="bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap w-9 sm:w-20">
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100" id="new-workout" hx-target="closest tr"
|
||||
hx-swap="outerHTML swap:0.5s">
|
||||
{% for top_set in top_sets %}
|
||||
{{ render_partial('partials/topset.html', person_id=person_id,
|
||||
workout_id=workout_id, **top_set) }}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if top_sets|length == 0 %}
|
||||
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert"
|
||||
id="no-workouts">
|
||||
No sets recorded for this session.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="exercise-progress" class="mx-0 md:mx-5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load"
|
||||
hx-target="#stats" hx-swap="innerHTML">
|
||||
</div>
|
||||
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load"
|
||||
hx-target="#stats" hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
45
utils.py
45
utils.py
@@ -1,5 +1,6 @@
|
||||
import colorsys
|
||||
from datetime import datetime, date, timedelta
|
||||
from flask import request
|
||||
import numpy as np
|
||||
|
||||
|
||||
@@ -33,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
|
||||
@@ -47,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 = {
|
||||
@@ -341,4 +346,18 @@ def prepare_svg_plot_data(results, columns, title):
|
||||
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
710
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user