Compare commits
49 Commits
62080b97a4
...
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 |
@@ -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
|
||||
155
app.py
155
app.py
@@ -1,5 +1,11 @@
|
||||
from datetime import date
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file in non-production environments
|
||||
if os.environ.get('FLASK_ENV') != 'production':
|
||||
load_dotenv()
|
||||
|
||||
from datetime import date
|
||||
from flask import Flask, abort, render_template, redirect, request, url_for
|
||||
from flask_login import LoginManager, login_required, current_user
|
||||
import jinja_partials
|
||||
@@ -16,18 +22,23 @@ from routes.endpoints import endpoints_bp # Import the new endpoints blueprint
|
||||
from routes.export import export_bp # Import the new export blueprint
|
||||
from routes.tags import tags_bp # Import the new tags blueprint
|
||||
from routes.programs import programs_bp # Import the new programs blueprint
|
||||
from routes.exercises import exercises_bp # Import the new exercises blueprint
|
||||
from routes.settings import settings_bp # Import the new settings blueprint
|
||||
from extensions import db
|
||||
from utils import convert_str_to_date
|
||||
from flask_htmx import HTMX
|
||||
import minify_html
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file in non-production environments
|
||||
if os.environ.get('FLASK_ENV') != 'production':
|
||||
load_dotenv()
|
||||
from flask_compress import Compress
|
||||
from flask_caching import Cache
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['COMPRESS_REGISTER'] = True
|
||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 31536000 # 1 year
|
||||
app.config['CACHE_TYPE'] = 'SimpleCache'
|
||||
app.config['CACHE_DEFAULT_TIMEOUT'] = 300 # 5 minutes
|
||||
|
||||
Compress(app)
|
||||
cache = Cache(app)
|
||||
app.config.from_pyfile('config.py')
|
||||
app.secret_key = os.environ.get('SECRET_KEY', '2a661781919643cb8a5a8bc57642d99f')
|
||||
jinja_partials.register_extensions(app)
|
||||
@@ -62,6 +73,8 @@ app.register_blueprint(endpoints_bp) # Register the endpoints blueprint (prefix
|
||||
app.register_blueprint(export_bp) # Register the export blueprint (prefix defined in blueprint file)
|
||||
app.register_blueprint(tags_bp) # Register the tags blueprint (prefix defined in blueprint file)
|
||||
app.register_blueprint(programs_bp) # Register the programs blueprint (prefix defined in blueprint file)
|
||||
app.register_blueprint(exercises_bp) # Register the exercises blueprint
|
||||
app.register_blueprint(settings_bp) # Register the settings blueprint
|
||||
|
||||
@app.after_request
|
||||
def response_minify(response):
|
||||
@@ -136,7 +149,10 @@ def person_overview(person_id):
|
||||
if not selected_exercise_ids and htmx.trigger_name != 'exercise_id':
|
||||
selected_exercise_ids = db.person_overview.list_of_performed_exercise_ids(person_id, min_date, max_date)
|
||||
|
||||
person = db.person_overview.get(person_id, min_date, max_date, selected_exercise_ids)
|
||||
limit = request.args.get('limit', type=int, default=20)
|
||||
offset = request.args.get('offset', type=int, default=0)
|
||||
|
||||
person = db.person_overview.get(person_id, min_date, max_date, selected_exercise_ids, limit=limit, offset=offset)
|
||||
exercises = db.person_overview.get_exercises_with_selection(person_id, min_date, max_date, selected_exercise_ids)
|
||||
tags = db.get_tags_for_person(person_id)
|
||||
|
||||
@@ -147,10 +163,15 @@ def person_overview(person_id):
|
||||
"tags": tags,
|
||||
"selected_exercise_ids": selected_exercise_ids,
|
||||
"max_date": max_date,
|
||||
"min_date": min_date
|
||||
"min_date": min_date,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"next_offset": offset + limit
|
||||
}
|
||||
|
||||
if htmx:
|
||||
if htmx.target == 'load-more-row':
|
||||
return render_template('partials/workout_rows.html', **render_args)
|
||||
return render_block(app.jinja_env, 'person_overview.html', 'content', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
|
||||
|
||||
return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
|
||||
@@ -160,6 +181,7 @@ def person_overview(person_id):
|
||||
def create_person():
|
||||
name = request.form.get("name")
|
||||
new_person_id = db.create_person(name)
|
||||
db.activityRequest.log(current_user.id, 'CREATE_PERSON', 'person', new_person_id, f"Created person: {name}")
|
||||
return render_template('partials/person.html', person_id=new_person_id, name=name), 200, {"HX-Trigger": "updatedPeople"}
|
||||
|
||||
|
||||
@@ -168,7 +190,9 @@ def create_person():
|
||||
@admin_required
|
||||
@validate_person
|
||||
def delete_person(person_id):
|
||||
name = db.get_person_name(person_id)
|
||||
db.delete_person(person_id)
|
||||
db.activityRequest.log(current_user.id, 'DELETE_PERSON', 'person', person_id, f"Deleted person: {name}")
|
||||
return "", 200, {"HX-Trigger": "updatedPeople"}
|
||||
|
||||
|
||||
@@ -187,7 +211,9 @@ def get_person_edit_form(person_id):
|
||||
@require_ownership
|
||||
def update_person_name(person_id):
|
||||
new_name = request.form.get("name")
|
||||
old_name = db.get_person_name(person_id)
|
||||
db.update_person_name(person_id, new_name)
|
||||
db.activityRequest.log(current_user.id, 'UPDATE_PERSON_NAME', 'person', person_id, f"Updated name for {old_name} to {new_name}")
|
||||
return render_template('partials/person.html', person_id=person_id, name=new_name), 200, {"HX-Trigger": "updatedPeople"}
|
||||
|
||||
|
||||
@@ -197,87 +223,10 @@ def get_person_name(person_id):
|
||||
return render_template('partials/person.html', person_id=person_id, name=name)
|
||||
|
||||
|
||||
@ app.route("/exercise", methods=['POST'])
|
||||
@login_required
|
||||
def create_exercise():
|
||||
name = request.form.get("name")
|
||||
attribute_ids = request.form.getlist('attribute_ids')
|
||||
exercise = db.create_exercise(name, attribute_ids)
|
||||
return render_template('partials/exercise.html',
|
||||
exercise_id=exercise['exercise_id'],
|
||||
name=exercise['name'],
|
||||
attributes=exercise['attributes'])
|
||||
|
||||
|
||||
@ app.route("/exercise/<int:exercise_id>", methods=['GET'])
|
||||
def get_exercise(exercise_id):
|
||||
exercise = db.get_exercise(exercise_id)
|
||||
return render_template('partials/exercise.html',
|
||||
exercise_id=exercise_id,
|
||||
name=exercise['name'],
|
||||
attributes=exercise['attributes'])
|
||||
|
||||
|
||||
@ app.route("/exercise/<int:exercise_id>/edit_form", methods=['GET'])
|
||||
@login_required
|
||||
def get_exercise_edit_form(exercise_id):
|
||||
exercise = db.get_exercise(exercise_id)
|
||||
all_attributes = db.exercises.get_attributes_by_category()
|
||||
|
||||
# Format options for custom_select
|
||||
formatted_options = {}
|
||||
ex_attr_ids = [a['attribute_id'] for a in exercise['attributes']]
|
||||
for cat, attrs in all_attributes.items():
|
||||
formatted_options[cat] = [
|
||||
{
|
||||
"id": a['attribute_id'],
|
||||
"name": a['name'],
|
||||
"selected": a['attribute_id'] in ex_attr_ids
|
||||
} for a in attrs
|
||||
]
|
||||
|
||||
return render_template('partials/exercise.html',
|
||||
exercise_id=exercise_id,
|
||||
name=exercise['name'],
|
||||
attributes=exercise['attributes'],
|
||||
all_attributes=formatted_options,
|
||||
is_edit=True)
|
||||
|
||||
|
||||
@ app.route("/exercise/<int:exercise_id>/update", methods=['PUT'])
|
||||
@login_required
|
||||
def update_exercise(exercise_id):
|
||||
new_name = request.form.get('name')
|
||||
attribute_ids = request.form.getlist('attribute_ids')
|
||||
exercise = db.update_exercise(exercise_id, new_name, attribute_ids)
|
||||
return render_template('partials/exercise.html',
|
||||
exercise_id=exercise_id,
|
||||
name=exercise['name'],
|
||||
attributes=exercise['attributes'])
|
||||
|
||||
|
||||
""" @ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
|
||||
def delete_exercise(exercise_id):
|
||||
db.delete_exercise(exercise_id)
|
||||
return "" """
|
||||
|
||||
|
||||
@ app.route("/settings")
|
||||
@ login_required
|
||||
def settings():
|
||||
people = db.get_people()
|
||||
exercises = db.get_all_exercises()
|
||||
all_attributes = db.exercises.get_attributes_by_category()
|
||||
|
||||
# Format options for custom_select
|
||||
formatted_options = {}
|
||||
for cat, attrs in all_attributes.items():
|
||||
formatted_options[cat] = [{"id": a['attribute_id'], "name": a['name']} for a in attrs]
|
||||
|
||||
if htmx:
|
||||
return render_block(app.jinja_env, "settings.html", "content",
|
||||
people=people, exercises=exercises, all_attributes=formatted_options), 200, {"HX-Trigger": "updatedPeople"}
|
||||
return render_template('settings.html', people=people, exercises=exercises, all_attributes=formatted_options)
|
||||
|
||||
|
||||
# Routes moved to routes/tags.py blueprint
|
||||
@@ -302,6 +251,7 @@ def get_exercise_progress_for_user(person_id, exercise_id):
|
||||
return render_template('partials/sparkline.html', **exercise_progress)
|
||||
|
||||
@app.route("/stats", methods=['GET'])
|
||||
@cache.cached(timeout=300, query_string=True)
|
||||
def get_stats():
|
||||
selected_people_ids = request.args.getlist('person_id', type=int)
|
||||
min_date = request.args.get('min_date', type=convert_str_to_date)
|
||||
@@ -311,6 +261,7 @@ def get_stats():
|
||||
return render_template('partials/stats.html', stats=stats, refresh_url=request.full_path)
|
||||
|
||||
@app.route("/graphs", methods=['GET'])
|
||||
@cache.cached(timeout=300, query_string=True)
|
||||
def get_people_graphs():
|
||||
selected_people_ids = request.args.getlist('person_id', type=int)
|
||||
min_date = request.args.get('min_date', type=convert_str_to_date)
|
||||
@@ -321,39 +272,7 @@ def get_people_graphs():
|
||||
|
||||
return render_template('partials/people_graphs.html', graphs=graphs, refresh_url=request.full_path)
|
||||
|
||||
@app.route("/exercises/get")
|
||||
def get_exercises():
|
||||
query = request.args.get('query')
|
||||
person_id = request.args.get('person_id', type=int)
|
||||
exercises = db.exercises.get(query)
|
||||
return render_template('partials/exercise/exercise_dropdown.html', exercises=exercises, person_id=person_id)
|
||||
|
||||
@app.route("/exercise/<int:exercise_id>/edit_name", methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_exercise_name(exercise_id):
|
||||
exercise = db.exercises.get_exercise(exercise_id)
|
||||
person_id = request.args.get('person_id', type=int)
|
||||
if request.method == 'GET':
|
||||
return render_template('partials/exercise/edit_exercise_name.html', exercise=exercise, person_id=person_id)
|
||||
else:
|
||||
updated_name = request.form['name']
|
||||
updated_exercise = db.exercises.update_exercise_name(exercise_id, updated_name)
|
||||
return render_template('partials/exercise/exercise_list_item.html', exercise=updated_exercise, person_id=person_id)
|
||||
|
||||
@app.route("/exercises/add", methods=['POST'])
|
||||
@login_required
|
||||
def add_exercise():
|
||||
exercise_name = request.form['query']
|
||||
new_exercise = db.exercises.add_exercise(exercise_name)
|
||||
person_id = request.args.get('person_id', type=int)
|
||||
return render_template('partials/exercise/exercise_list_item.html', exercise=new_exercise, person_id=person_id)
|
||||
|
||||
@ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_exercise(exercise_id):
|
||||
db.exercises.delete_exercise(exercise_id)
|
||||
return ""
|
||||
|
||||
@app.teardown_appcontext
|
||||
def closeConnection(exception):
|
||||
|
||||
82
db.py
82
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:
|
||||
@@ -376,6 +380,28 @@ class DataBase():
|
||||
else:
|
||||
return (topset.get('repetitions'), topset.get('weight'), topset['exercise_name'])
|
||||
|
||||
def get_recent_topsets_for_exercise(self, person_id, exercise_id, limit=5, offset=0):
|
||||
topsets = self.execute("""
|
||||
SELECT
|
||||
t.topset_id,
|
||||
t.repetitions,
|
||||
t.weight,
|
||||
w.start_date,
|
||||
w.workout_id,
|
||||
e.name AS "exercise_name"
|
||||
FROM
|
||||
exercise e
|
||||
JOIN topset t ON e.exercise_id = t.exercise_id
|
||||
JOIN workout w ON t.workout_id = w.workout_id
|
||||
WHERE
|
||||
e.exercise_id = %s AND w.person_id = %s
|
||||
ORDER BY
|
||||
w.start_date DESC, t.topset_id DESC
|
||||
LIMIT %s OFFSET %s;
|
||||
""", [exercise_id, person_id, limit, offset])
|
||||
|
||||
return topsets
|
||||
|
||||
def get_all_exercises(self):
|
||||
return self.exercises.get("")
|
||||
|
||||
@@ -404,8 +430,8 @@ class DataBase():
|
||||
WHERE
|
||||
W.person_id = %s
|
||||
AND E.exercise_id = %s AND
|
||||
(%s IS NULL OR W.start_date >= %s) AND
|
||||
(%s IS NULL OR W.start_date <= %s)
|
||||
(%s::date IS NULL OR W.start_date >= %s::date) AND
|
||||
(%s::date IS NULL OR W.start_date <= %s::date)
|
||||
ORDER BY
|
||||
W.start_date;
|
||||
""", [person_id, exercise_id, min_date, min_date, max_date, max_date])
|
||||
@@ -422,6 +448,11 @@ class DataBase():
|
||||
start_dates = [t['start_date'] for t in topsets]
|
||||
messages = [f'{t["repetitions"]} x {t["weight"]}kg ({t["estimated_1rm"]}kg E1RM) on {t["start_date"].strftime("%d %b %y")}' for t in topsets]
|
||||
|
||||
# Get the latest topset info for badges
|
||||
latest_topset = topsets[-1]
|
||||
latest_topset_id = latest_topset['topset_id']
|
||||
latest_workout_id = latest_topset['workout_id']
|
||||
|
||||
exercise_progress = get_exercise_graph_model(
|
||||
exercise_name,
|
||||
estimated_1rm,
|
||||
@@ -436,6 +467,9 @@ class DataBase():
|
||||
max_date,
|
||||
degree)
|
||||
|
||||
exercise_progress['latest_topset_id'] = latest_topset_id
|
||||
exercise_progress['latest_workout_id'] = latest_workout_id
|
||||
|
||||
return exercise_progress
|
||||
|
||||
# Note fetching logic moved to routes/notes.py
|
||||
|
||||
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,15 +3,41 @@ class Exercises:
|
||||
self.execute = db_connection_method
|
||||
|
||||
def get(self, query):
|
||||
# Add wildcards to the query
|
||||
search_query = f"%{query}%"
|
||||
# We need to fetch exercises with their attributes.
|
||||
# Since an exercise can have many attributes, we'll fetch basic info first or use a join.
|
||||
# But wait, the settings page just lists names. We can fetch attributes separately for each row or do a group_concat-like join.
|
||||
# However, for the settings list, we want to show the tags.
|
||||
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
|
||||
|
||||
# Let's use a simpler approach: fetch exercises and then for each one (or via a single join) get attributes.
|
||||
exercises = self.execute("SELECT exercise_id, name FROM exercise WHERE LOWER(name) LIKE LOWER(%s) ORDER BY name ASC;", [search_query])
|
||||
# Check for category:value syntax
|
||||
if ':' in query:
|
||||
category_part, value_part = query.split(':', 1)
|
||||
category_part = f"%{category_part.strip().lower()}%"
|
||||
value_part = f"%{value_part.strip().lower()}%"
|
||||
|
||||
query = """
|
||||
SELECT DISTINCT e.exercise_id, e.name
|
||||
FROM exercise e
|
||||
JOIN exercise_to_attribute eta ON e.exercise_id = eta.exercise_id
|
||||
JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
|
||||
JOIN exercise_attribute_category cat ON attr.category_id = cat.category_id
|
||||
WHERE LOWER(cat.name) LIKE LOWER(%s) AND LOWER(attr.name) LIKE LOWER(%s)
|
||||
ORDER BY e.name ASC;
|
||||
"""
|
||||
exercises = self.execute(query, [category_part, value_part])
|
||||
else:
|
||||
# Fallback: search in name OR attribute name
|
||||
search_term = query.strip().lower()
|
||||
search_query = f"%{search_term}%"
|
||||
query = """
|
||||
SELECT DISTINCT e.exercise_id, e.name
|
||||
FROM exercise e
|
||||
LEFT JOIN exercise_to_attribute eta ON e.exercise_id = eta.exercise_id
|
||||
LEFT JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
|
||||
WHERE LOWER(e.name) LIKE LOWER(%s) OR LOWER(attr.name) LIKE LOWER(%s)
|
||||
ORDER BY e.name ASC;
|
||||
"""
|
||||
exercises = self.execute(query, [search_query, search_query])
|
||||
|
||||
for ex in exercises:
|
||||
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
|
||||
@@ -121,3 +147,38 @@ class Exercises:
|
||||
|
||||
return distribution
|
||||
|
||||
# Category Management
|
||||
def add_category(self, name):
|
||||
result = self.execute('INSERT INTO exercise_attribute_category (name) VALUES (%s) RETURNING category_id, name', [name], commit=True, one=True)
|
||||
return result
|
||||
|
||||
def update_category(self, category_id, name):
|
||||
self.execute('UPDATE exercise_attribute_category SET name = %s WHERE category_id = %s', [name, category_id], commit=True)
|
||||
return {"category_id": category_id, "name": name}
|
||||
|
||||
def delete_category(self, category_id):
|
||||
# First delete all attributes in this category
|
||||
attributes = self.execute('SELECT attribute_id FROM exercise_attribute WHERE category_id = %s', [category_id])
|
||||
for attr in attributes:
|
||||
self.delete_attribute(attr['attribute_id'])
|
||||
|
||||
self.execute('DELETE FROM exercise_attribute_category WHERE category_id = %s', [category_id], commit=True)
|
||||
|
||||
# Attribute Management
|
||||
def add_attribute(self, name, category_id):
|
||||
result = self.execute('INSERT INTO exercise_attribute (name, category_id) VALUES (%s, %s) RETURNING attribute_id, name, category_id', [name, category_id], commit=True, one=True)
|
||||
return result
|
||||
|
||||
def update_attribute(self, attribute_id, name, category_id=None):
|
||||
if category_id:
|
||||
self.execute('UPDATE exercise_attribute SET name = %s, category_id = %s WHERE attribute_id = %s', [name, category_id, attribute_id], commit=True)
|
||||
else:
|
||||
self.execute('UPDATE exercise_attribute SET name = %s WHERE attribute_id = %s', [name, attribute_id], commit=True)
|
||||
return self.execute('SELECT attribute_id, name, category_id FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], one=True)
|
||||
|
||||
def delete_attribute(self, attribute_id):
|
||||
# Remove from all exercises first
|
||||
self.execute('DELETE FROM exercise_to_attribute WHERE attribute_id = %s', [attribute_id], commit=True)
|
||||
# Delete the attribute
|
||||
self.execute('DELETE FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], commit=True)
|
||||
|
||||
|
||||
@@ -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,6 +173,7 @@ class PersonOverview:
|
||||
# Add topset to the corresponding exercise
|
||||
if row["exercise_id"] and row["topset_id"]:
|
||||
# Add to workout exercises
|
||||
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"]
|
||||
@@ -167,9 +188,8 @@ class PersonOverview:
|
||||
"exercise_name": row["exercise_name"]
|
||||
})
|
||||
|
||||
# Transform into a list of rows
|
||||
for workout_id, workout in workout_map.items():
|
||||
workouts.append(workout)
|
||||
# Transform into a list of rows, maintaining DESC order
|
||||
workouts = [workout_map[wid] for wid in target_workout_ids if wid in workout_map]
|
||||
|
||||
exercise_progress_graphs = self.generate_exercise_progress_graphs(person_info["person_id"], exercise_sets)
|
||||
|
||||
@@ -177,7 +197,8 @@ class PersonOverview:
|
||||
**person_info,
|
||||
"workouts": workouts,
|
||||
"selected_exercises": exercises,
|
||||
"exercise_progress_graphs": exercise_progress_graphs
|
||||
"exercise_progress_graphs": exercise_progress_graphs,
|
||||
"has_more": has_more
|
||||
}
|
||||
|
||||
def generate_exercise_progress_graphs(self, person_id, exercise_sets):
|
||||
|
||||
6
main.py
Normal file
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,6 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask_login import login_user, login_required, logout_user
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from forms.login import LoginForm
|
||||
from forms.signup import SignupForm
|
||||
from extensions import db
|
||||
@@ -105,11 +105,12 @@ def signup():
|
||||
form = SignupForm()
|
||||
if form.validate_on_submit():
|
||||
hashed_password = generate_password_hash(form.password.data)
|
||||
create_person(
|
||||
new_person_id = create_person(
|
||||
name=form.name.data,
|
||||
email=form.email.data,
|
||||
password_hash=hashed_password
|
||||
)
|
||||
db.activityRequest.log(new_person_id, 'SIGNUP', 'person', new_person_id, f"User signed up: {form.email.data}")
|
||||
flash("Account created successfully. Please log in.", "success")
|
||||
return redirect(url_for('auth.login'))
|
||||
return render_template('auth/signup.html', form=form)
|
||||
@@ -121,12 +122,12 @@ def login():
|
||||
if form.validate_on_submit():
|
||||
person = get_person_by_email(form.email.data)
|
||||
if person and check_password_hash(person.password_hash, form.password.data):
|
||||
login_user(person)
|
||||
record_login_attempt(form.email.data, True, person.id)
|
||||
login_user(person, remember=True)
|
||||
db.activityRequest.log(person.id, 'LOGIN_SUCCESS', 'person', person.id, f"User logged in: {form.email.data}")
|
||||
flash("Logged in successfully.", "success")
|
||||
return redirect(url_for('calendar.get_calendar', person_id=person.id))
|
||||
else:
|
||||
record_login_attempt(form.email.data, False, person.id if person else None)
|
||||
db.activityRequest.log(person.id if person else None, 'LOGIN_FAILURE', 'person', person.id if person else None, f"Failed login attempt for: {form.email.data}")
|
||||
flash("Invalid email or password.", "danger")
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@@ -134,6 +135,8 @@ def login():
|
||||
@auth.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
person_id = current_user.id if current_user.is_authenticated else None
|
||||
logout_user()
|
||||
db.activityRequest.log(person_id, 'LOGOUT', 'person', person_id, "User logged out")
|
||||
flash('You have been logged out.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@@ -36,6 +36,7 @@ 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 = """
|
||||
WITH workout_stats AS (
|
||||
SELECT
|
||||
w.workout_id,
|
||||
w.start_date,
|
||||
@@ -43,18 +44,36 @@ def _fetch_workout_data(db_executor, person_id, start_date, end_date, include_de
|
||||
t.repetitions,
|
||||
t.weight,
|
||||
e.name AS exercise_name,
|
||||
p.name AS person_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 AND w.start_date BETWEEN %s AND %s
|
||||
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
|
||||
ORDER BY
|
||||
w.start_date,
|
||||
t.topset_id;
|
||||
)
|
||||
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,7 +88,6 @@ 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])
|
||||
|
||||
def _group_workouts_by_date(workouts_data):
|
||||
@@ -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,11 +1,14 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, current_app
|
||||
import os
|
||||
import json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, current_app, flash, jsonify
|
||||
from extensions import db
|
||||
from flask_login import login_required, current_user
|
||||
from jinja2_fragments import render_block # Import render_block
|
||||
from jinja2_fragments import render_block
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
from flask_htmx import HTMX
|
||||
|
||||
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
|
||||
|
||||
from flask import flash # Import flash for displaying messages
|
||||
htmx = HTMX()
|
||||
|
||||
@programs_bp.route('/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -16,256 +19,380 @@ def create_program():
|
||||
sessions_data = []
|
||||
i = 0
|
||||
while True:
|
||||
# Check for the presence of session order to determine if the session exists
|
||||
session_order_key = f'session_order_{i}'
|
||||
if session_order_key not in request.form:
|
||||
break # No more sessions
|
||||
break
|
||||
|
||||
session_order = request.form.get(session_order_key)
|
||||
session_name = request.form.get(f'session_name_{i}', '').strip()
|
||||
# Get list of selected exercise IDs for this session
|
||||
exercise_ids_str = request.form.getlist(f'exercises_{i}')
|
||||
sets_list = request.form.getlist(f'sets_{i}')
|
||||
reps_list = request.form.getlist(f'reps_{i}')
|
||||
|
||||
# Basic validation for session data
|
||||
if not exercise_ids_str or not session_order:
|
||||
flash(f"Error processing session {i+1}: Missing exercises or order.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
flash(f"Error processing session {i+1}: Missing exercises.", "error")
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
try:
|
||||
# Convert exercise IDs to integers and sort them for consistent filter generation
|
||||
exercise_ids = sorted([int(eid) for eid in exercise_ids_str])
|
||||
exercise_data = []
|
||||
for idx, eid in enumerate(exercise_ids_str):
|
||||
exercise_data.append({
|
||||
'id': int(eid),
|
||||
'sets': int(sets_list[idx]) if idx < len(sets_list) and sets_list[idx] else None,
|
||||
'rep_range': reps_list[idx] if idx < len(reps_list) else None,
|
||||
'order': idx + 1
|
||||
})
|
||||
|
||||
sessions_data.append({
|
||||
'order': int(session_order),
|
||||
'name': session_name if session_name else None, # Store None if empty
|
||||
'exercise_ids': exercise_ids # Store the list of exercise IDs
|
||||
'name': session_name if session_name else None,
|
||||
'exercises': exercise_data
|
||||
})
|
||||
except ValueError:
|
||||
flash(f"Error processing session {i+1}: Invalid exercise ID or order.", "error")
|
||||
flash(f"Error processing session {i+1}: Invalid data.", "error")
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
i += 1
|
||||
|
||||
# --- Validation ---
|
||||
if not program_name:
|
||||
flash("Program Name is required.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
if not sessions_data:
|
||||
flash("At least one session must be added.", "error")
|
||||
# TODO: Re-render form preserving entered data
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
# --- Database Insertion ---
|
||||
try:
|
||||
# Insert Program
|
||||
program_result = db.execute(
|
||||
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
|
||||
[program_name, description if description else None],
|
||||
commit=True, one=True
|
||||
)
|
||||
if not program_result or 'program_id' not in program_result:
|
||||
raise Exception("Failed to create workout program entry.")
|
||||
|
||||
new_program_id = program_result['program_id']
|
||||
|
||||
# Insert Sessions (and find/create tags)
|
||||
for session in sessions_data:
|
||||
# 1. Generate the canonical filter string from sorted exercise IDs
|
||||
if not session['exercise_ids']:
|
||||
flash(f"Session {session['order']} must have at least one exercise selected.", "error")
|
||||
# Ideally, rollback program insert or handle differently
|
||||
return redirect(url_for('programs.create_program'))
|
||||
exercise_ids = sorted([ex['id'] for ex in session['exercises']])
|
||||
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
|
||||
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises"
|
||||
|
||||
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in session['exercise_ids'])
|
||||
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises" # Default tag name
|
||||
|
||||
# 2. Find existing tag with this exact filter (non-person specific)
|
||||
existing_tag = db.execute(
|
||||
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
||||
[tag_filter], one=True
|
||||
)
|
||||
|
||||
session_tag_id = None
|
||||
if existing_tag:
|
||||
session_tag_id = existing_tag['tag_id']
|
||||
# Optional: Update tag name if session name provided and different?
|
||||
# db.execute("UPDATE tag SET name = %s WHERE tag_id = %s", [tag_name, session_tag_id], commit=True)
|
||||
else:
|
||||
# 3. Create new tag if not found
|
||||
# Ensure tag name uniqueness if desired (e.g., append number if name exists)
|
||||
# For simplicity, allow duplicate names for now, rely on filter for uniqueness
|
||||
new_tag_result = db.execute(
|
||||
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
||||
[tag_name, tag_filter], commit=True, one=True
|
||||
)
|
||||
if not new_tag_result or 'tag_id' not in new_tag_result:
|
||||
raise Exception(f"Failed to create tag for session {session['order']}.")
|
||||
session_tag_id = new_tag_result['tag_id']
|
||||
|
||||
# 4. Insert program_session using the found/created tag_id
|
||||
db.execute(
|
||||
"""INSERT INTO program_session (program_id, session_order, session_name, tag_id)
|
||||
VALUES (%s, %s, %s, %s)""",
|
||||
session_record = db.execute(
|
||||
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
|
||||
"VALUES (%s, %s, %s, %s) RETURNING session_id",
|
||||
[new_program_id, session['order'], session['name'], session_tag_id],
|
||||
commit=True # Commit each session insert
|
||||
commit=True, one=True
|
||||
)
|
||||
session_id = session_record['session_id']
|
||||
|
||||
for ex in session['exercises']:
|
||||
db.execute(
|
||||
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
[session_id, ex['id'], ex['sets'], ex['rep_range'], ex['order']],
|
||||
commit=True
|
||||
)
|
||||
|
||||
flash(f"Workout Program '{program_name}' created successfully!", "success")
|
||||
# TODO: Redirect to a program view page once it exists
|
||||
# return redirect(url_for('programs.view_program', program_id=new_program_id))
|
||||
return redirect(url_for('programs.list_programs')) # Redirect to a list page for now
|
||||
return redirect(url_for('programs.view_program', program_id=new_program_id))
|
||||
|
||||
except Exception as e:
|
||||
# Log the error e
|
||||
print(f"Error creating program: {e}") # Basic logging
|
||||
print(f"Error creating program: {e}")
|
||||
flash(f"Database error creating program: {e}", "error")
|
||||
# Rollback might be needed if using transactions across inserts
|
||||
return redirect(url_for('programs.create_program'))
|
||||
|
||||
else: # GET Request
|
||||
# Fetch all available exercises to populate multi-selects
|
||||
else:
|
||||
exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
|
||||
if exercises is None:
|
||||
exercises = [] # Ensure exercises is an iterable
|
||||
return render_template('program_create.html', exercises=exercises if exercises else [])
|
||||
|
||||
# Pass exercises to the template context
|
||||
return render_template('program_create.html', exercises=exercises, render_block=render_block) # Pass exercises instead of tags
|
||||
|
||||
|
||||
from flask_htmx import HTMX # Import HTMX
|
||||
|
||||
htmx = HTMX() # Initialize HTMX if not already done globally
|
||||
|
||||
# Placeholder for program list route (used in POST redirect)
|
||||
@programs_bp.route('/', methods=['GET'])
|
||||
# @login_required
|
||||
@login_required
|
||||
def list_programs():
|
||||
# Fetch and display list of programs
|
||||
programs = db.execute("SELECT program_id, name, description FROM workout_program ORDER BY created_at DESC")
|
||||
if programs is None:
|
||||
programs = []
|
||||
|
||||
# Check if it's an HTMX request
|
||||
if htmx:
|
||||
# Render only the content block for HTMX requests
|
||||
# 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
|
||||
|
||||
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)
|
||||
|
||||
@programs_bp.route('/import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def import_program():
|
||||
htmx_req = request.headers.get('HX-Request')
|
||||
if request.method == 'POST':
|
||||
if 'file' not in request.files:
|
||||
flash("No file part", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
flash("No selected file", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
try:
|
||||
data = json.load(file)
|
||||
program_name = data.get('program_name', 'Imported Program')
|
||||
description = data.get('description', '')
|
||||
|
||||
program_result = db.execute(
|
||||
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
|
||||
[program_name, description],
|
||||
commit=True, one=True
|
||||
)
|
||||
new_program_id = program_result['program_id']
|
||||
|
||||
for session_data in data.get('sessions', []):
|
||||
order = session_data.get('order')
|
||||
name = session_data.get('name')
|
||||
exercises_list = session_data.get('exercises', [])
|
||||
|
||||
exercise_ids = sorted([int(ex['id']) for ex in exercises_list])
|
||||
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
|
||||
tag_name = name if name else f"Session {order} Exercises"
|
||||
|
||||
existing_tag = db.execute(
|
||||
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
||||
[tag_filter], one=True
|
||||
)
|
||||
|
||||
if existing_tag:
|
||||
tag_id = existing_tag['tag_id']
|
||||
else:
|
||||
new_tag_result = db.execute(
|
||||
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
||||
[tag_name, tag_filter], commit=True, one=True
|
||||
)
|
||||
tag_id = new_tag_result['tag_id']
|
||||
|
||||
session_result = db.execute(
|
||||
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
|
||||
"VALUES (%s, %s, %s, %s) RETURNING session_id",
|
||||
[new_program_id, order, name, tag_id],
|
||||
commit=True, one=True
|
||||
)
|
||||
session_id = session_result['session_id']
|
||||
|
||||
for ex in exercises_list:
|
||||
db.execute(
|
||||
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
[session_id, ex['id'], ex.get('sets'), ex.get('rep_range'), ex.get('order')],
|
||||
commit=True
|
||||
)
|
||||
|
||||
flash(f"Program '{program_name}' imported successfully!", "success")
|
||||
return redirect(url_for('programs.view_program', program_id=new_program_id))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error importing program: {e}", "error")
|
||||
return redirect(url_for('programs.list_programs'))
|
||||
|
||||
if htmx_req:
|
||||
return render_block(current_app.jinja_env, 'program_import.html', 'content')
|
||||
return render_template('program_import.html')
|
||||
|
||||
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_program(program_id):
|
||||
"""Deletes a workout program and its associated sessions/assignments."""
|
||||
try:
|
||||
# The ON DELETE CASCADE constraint on program_session and person_program_assignment
|
||||
# should handle deleting related rows automatically when the program is deleted.
|
||||
result = db.execute(
|
||||
"DELETE FROM workout_program WHERE program_id = %s RETURNING program_id",
|
||||
[program_id],
|
||||
commit=True, one=True
|
||||
)
|
||||
if result and result.get('program_id') == program_id:
|
||||
# Return empty response for HTMX, maybe trigger list refresh
|
||||
# flash(f"Program ID {program_id} deleted successfully.", "success") # Flash might not show on empty response
|
||||
response = "" # Empty response indicates success to HTMX
|
||||
headers = {"HX-Trigger": "programDeleted"} # Trigger event for potential list refresh
|
||||
return response, 200, headers
|
||||
else:
|
||||
# Program not found or delete failed silently
|
||||
flash(f"Could not find or delete program ID {program_id}.", "error")
|
||||
# Returning an error status might be better for HTMX error handling
|
||||
return "Error: Program not found or deletion failed", 404
|
||||
|
||||
db.execute("DELETE FROM workout_program WHERE program_id = %s", [program_id], commit=True)
|
||||
return "", 200
|
||||
except Exception as e:
|
||||
# Log the error e
|
||||
print(f"Error deleting program {program_id}: {e}")
|
||||
flash(f"Database error deleting program: {e}", "error")
|
||||
# Return an error status for HTMX
|
||||
return "Server error during deletion", 500
|
||||
|
||||
|
||||
# TODO: Add routes for viewing, editing, and assigning programs
|
||||
from urllib.parse import parse_qs # Needed to parse tag filters
|
||||
return str(e), 500
|
||||
|
||||
@programs_bp.route('/<int:program_id>', methods=['GET'])
|
||||
# @login_required
|
||||
@login_required
|
||||
def view_program(program_id):
|
||||
"""Displays the details of a specific workout program."""
|
||||
# Fetch program details
|
||||
program = db.execute(
|
||||
"SELECT program_id, name, description, created_at FROM workout_program WHERE program_id = %s",
|
||||
[program_id], one=True
|
||||
)
|
||||
program = db.execute("SELECT * FROM workout_program WHERE program_id = %s", [program_id], one=True)
|
||||
if not program:
|
||||
flash(f"Workout Program with ID {program_id} not found.", "error")
|
||||
flash("Program not found.", "error")
|
||||
return redirect(url_for('programs.list_programs'))
|
||||
|
||||
# Fetch sessions and their associated tags
|
||||
sessions = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
ps.session_id, ps.session_order, ps.session_name,
|
||||
t.tag_id, t.name as tag_name, t.filter as tag_filter
|
||||
FROM program_session ps
|
||||
JOIN tag t ON ps.tag_id = t.tag_id
|
||||
WHERE ps.program_id = %s
|
||||
ORDER BY ps.session_order ASC
|
||||
""",
|
||||
[program_id]
|
||||
)
|
||||
sessions = db.execute("SELECT * FROM program_session WHERE program_id = %s ORDER BY session_order", [program_id])
|
||||
|
||||
# Process sessions to extract exercise IDs and fetch exercise names
|
||||
sessions_with_exercises = []
|
||||
if sessions:
|
||||
for session in sessions:
|
||||
exercise_ids = []
|
||||
if session.get('tag_filter'):
|
||||
# Parse the filter string (e.g., "?exercise_id=5&exercise_id=1009")
|
||||
parsed_filter = parse_qs(session['tag_filter'].lstrip('?'))
|
||||
exercise_ids_str = parsed_filter.get('exercise_id', [])
|
||||
try:
|
||||
# Ensure IDs are unique and sorted if needed, though order might matter from filter
|
||||
exercise_ids = sorted(list(set(int(eid) for eid in exercise_ids_str)))
|
||||
except ValueError:
|
||||
print(f"Warning: Could not parse exercise IDs from filter for tag {session['tag_id']}: {session['tag_filter']}")
|
||||
exercise_ids = [] # Handle parsing error gracefully
|
||||
|
||||
exercises = []
|
||||
if exercise_ids:
|
||||
# Fetch exercise details for the extracted IDs
|
||||
# Using tuple() for IN clause compatibility
|
||||
# Ensure tuple has at least one element for SQL IN clause
|
||||
if len(exercise_ids) == 1:
|
||||
exercises_tuple = (exercise_ids[0],) # Comma makes it a tuple
|
||||
else:
|
||||
exercises_tuple = tuple(exercise_ids)
|
||||
|
||||
exercises = db.execute(
|
||||
"SELECT exercise_id, name FROM exercise WHERE exercise_id IN %s ORDER BY name",
|
||||
[exercises_tuple]
|
||||
"""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 exercises is None: exercises = [] # Ensure it's iterable
|
||||
|
||||
sessions_with_exercises.append({
|
||||
**session, # Include all original session/tag data
|
||||
'exercises': exercises
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
@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'))
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
# Prepare context for the template
|
||||
context = {
|
||||
'program': program,
|
||||
'sessions': sessions_with_exercises
|
||||
}
|
||||
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))
|
||||
|
||||
# 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)
|
||||
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 existing_tag:
|
||||
session_tag_id = existing_tag['tag_id']
|
||||
else:
|
||||
return render_template('program_view.html', **context)
|
||||
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']
|
||||
|
||||
# TODO: Add routes for editing and assigning programs
|
||||
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']
|
||||
|
||||
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"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)
|
||||
@@ -80,7 +80,7 @@ def _delete_saved_query(query_id):
|
||||
|
||||
def _generate_sql_from_natural_language(natural_query):
|
||||
"""Generates SQL query from natural language using Gemini REST API."""
|
||||
gemni_model = os.environ.get("GEMINI_MODEL","gemini-2.0-flash")
|
||||
gemni_model = os.environ.get("GEMINI_MODEL","gemini-2.5-flash-lite")
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
return None, "GEMINI_API_KEY environment variable not set."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, current_app
|
||||
from jinja2_fragments import render_block
|
||||
from flask_htmx import HTMX
|
||||
from flask_login import login_required
|
||||
from flask_login import login_required, current_user
|
||||
from extensions import db
|
||||
from decorators import validate_workout, validate_topset, require_ownership, validate_person
|
||||
from utils import convert_str_to_date
|
||||
@@ -140,6 +140,7 @@ def _get_workout_view_model(person_id, workout_id):
|
||||
@require_ownership
|
||||
def create_workout(person_id):
|
||||
new_workout_id = db.create_workout(person_id)
|
||||
db.activityRequest.log(current_user.id, 'CREATE_WORKOUT', 'workout', new_workout_id, f"Created workout for person_id: {person_id}")
|
||||
# Use the local helper function to get the view model
|
||||
view_model = _get_workout_view_model(person_id, new_workout_id)
|
||||
if "error" in view_model: # Handle case where workout creation might fail or is empty
|
||||
@@ -153,6 +154,7 @@ def create_workout(person_id):
|
||||
@require_ownership
|
||||
def delete_workout(person_id, workout_id):
|
||||
db.delete_workout(workout_id)
|
||||
db.activityRequest.log(current_user.id, 'DELETE_WORKOUT', 'workout', workout_id, f"Deleted workout: {workout_id}")
|
||||
return redirect(url_for('calendar.get_calendar', person_id=person_id))
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date_edit_form", methods=['GET'])
|
||||
@@ -172,6 +174,7 @@ def get_workout_start_date_edit_form(person_id, workout_id):
|
||||
def update_workout_start_date(person_id, workout_id):
|
||||
new_start_date_str = request.form.get('start-date')
|
||||
db.update_workout_start_date(workout_id, new_start_date_str)
|
||||
db.activityRequest.log(current_user.id, 'UPDATE_WORKOUT_START_DATE', 'workout', workout_id, f"Updated start date to {new_start_date_str}")
|
||||
# Convert string back to date for rendering the partial
|
||||
new_start_date = convert_str_to_date(new_start_date_str, '%Y-%m-%d')
|
||||
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=new_start_date)
|
||||
@@ -213,7 +216,26 @@ def get_topset(person_id, workout_id, topset_id):
|
||||
def get_topset_edit_form(person_id, workout_id, topset_id):
|
||||
exercises = db.get_all_exercises()
|
||||
topset = db.get_topset(topset_id)
|
||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercises=exercises, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'), is_edit=True)
|
||||
|
||||
# Format exercises for custom_select
|
||||
formatted_exercises = [
|
||||
{
|
||||
"exercise_id": ex['exercise_id'],
|
||||
"name": ex['name'],
|
||||
"selected": ex['exercise_id'] == topset.get('exercise_id')
|
||||
} for ex in exercises
|
||||
]
|
||||
|
||||
return render_template('partials/topset.html',
|
||||
person_id=person_id,
|
||||
workout_id=workout_id,
|
||||
topset_id=topset_id,
|
||||
exercises=formatted_exercises,
|
||||
exercise_id=topset.get('exercise_id'),
|
||||
exercise_name=topset.get('exercise_name'),
|
||||
repetitions=topset.get('repetitions'),
|
||||
weight=topset.get('weight'),
|
||||
is_edit=True)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset", methods=['POST'])
|
||||
@login_required
|
||||
@@ -223,8 +245,19 @@ def create_topset(person_id, workout_id):
|
||||
exercise_id = request.form.get("exercise_id")
|
||||
repetitions = request.form.get("repetitions")
|
||||
weight = request.form.get("weight")
|
||||
|
||||
# Validation: Ensure exercise_id is present and is a valid integer
|
||||
if not exercise_id or not exercise_id.strip():
|
||||
return "Please select an exercise.", 400
|
||||
|
||||
try:
|
||||
exercise_id = int(exercise_id)
|
||||
except ValueError:
|
||||
return "Invalid exercise selection.", 400
|
||||
|
||||
new_topset_id = db.create_topset(workout_id, exercise_id, repetitions, weight)
|
||||
exercise = db.get_exercise(exercise_id)
|
||||
db.activityRequest.log(current_user.id, 'ADD_SET', 'topset', new_topset_id, f"Added set: {repetitions} x {weight}kg {exercise['name']} in workout {workout_id}")
|
||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=new_topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight), 200, {"HX-Trigger": "topsetAdded"}
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['PUT'])
|
||||
@@ -237,6 +270,7 @@ def update_topset(person_id, workout_id, topset_id):
|
||||
weight = request.form.get("weight")
|
||||
db.update_topset(exercise_id, repetitions, weight, topset_id)
|
||||
exercise = db.get_exercise(exercise_id)
|
||||
db.activityRequest.log(current_user.id, 'UPDATE_SET', 'topset', topset_id, f"Updated set {topset_id}: {repetitions} x {weight}kg {exercise['name']}")
|
||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/delete", methods=['DELETE'])
|
||||
@@ -244,7 +278,9 @@ def update_topset(person_id, workout_id, topset_id):
|
||||
@validate_topset
|
||||
@require_ownership
|
||||
def delete_topset(person_id, workout_id, topset_id):
|
||||
topset = db.get_topset(topset_id)
|
||||
db.delete_topset(topset_id)
|
||||
db.activityRequest.log(current_user.id, 'DELETE_SET', 'topset', topset_id, f"Deleted set {topset_id}: {topset['exercise_name']} in workout {workout_id}")
|
||||
return ""
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])
|
||||
@@ -260,6 +296,42 @@ def get_most_recent_topset_for_exercise(person_id, workout_id):
|
||||
(repetitions, weight, exercise_name) = topset
|
||||
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercise_id=exercise_id, exercise_name=exercise_name, repetitions=repetitions, weight=weight)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/exercise/<int:exercise_id>/history", methods=['GET'])
|
||||
def get_exercise_history(person_id, exercise_id):
|
||||
limit = request.args.get('limit', type=int, default=5)
|
||||
offset = request.args.get('offset', type=int, default=0)
|
||||
source_topset_id = request.args.get('source_topset_id', type=int)
|
||||
|
||||
topsets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit, offset)
|
||||
|
||||
title = "History"
|
||||
best_fit_formula = None
|
||||
latest_workout_id = None
|
||||
latest_topset_id = None
|
||||
|
||||
if topsets:
|
||||
# Fetch progress data to get title and best_fit_formula if there's history
|
||||
exercise_progress = db.get_exercise_progress_for_user(
|
||||
person_id, exercise_id, epoch='all')
|
||||
|
||||
if exercise_progress:
|
||||
title = exercise_progress.get('title', "History")
|
||||
best_fit_formula = exercise_progress.get('best_fit_formula')
|
||||
latest_workout_id = exercise_progress.get('latest_workout_id')
|
||||
latest_topset_id = exercise_progress.get('latest_topset_id')
|
||||
|
||||
return render_template('partials/exercise_history.html',
|
||||
person_id=person_id,
|
||||
exercise_id=exercise_id,
|
||||
topsets=topsets,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
source_topset_id=source_topset_id,
|
||||
title=title,
|
||||
best_fit_formula=best_fit_formula,
|
||||
latest_workout_id=latest_workout_id,
|
||||
latest_topset_id=latest_topset_id)
|
||||
|
||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>", methods=['GET'])
|
||||
def show_workout(person_id, workout_id):
|
||||
# Use the local helper function to get the view model
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
python-3.9.18
|
||||
@@ -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,7 +22,7 @@ tr.htmx-swapping td {
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
|
||||
#modal > .modal-underlay {
|
||||
#modal>.modal-underlay {
|
||||
/* underlay takes up the entire viewport. This is only
|
||||
required if you want to click to dismiss the popup */
|
||||
position: absolute;
|
||||
@@ -31,33 +31,33 @@ tr.htmx-swapping td {
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
#modal > .modal-content {
|
||||
#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);
|
||||
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 {
|
||||
#modal.closing {
|
||||
/* Animate when closing */
|
||||
animation-name: fadeOut;
|
||||
animation-duration: 150ms;
|
||||
animation-timing-function: ease;
|
||||
}
|
||||
}
|
||||
|
||||
#modal.closing > .modal-content {
|
||||
#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
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 272 KiB |
2029
static/js/mermaid.min.js
vendored
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
@@ -7,15 +7,16 @@
|
||||
|
||||
<title>Workout Tracker</title>
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/logo.png') }}">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" type="text/css"
|
||||
href="https://cdn.rawgit.com/dreampulse/computer-modern-web-font/master/fonts.css">
|
||||
|
||||
<!-- Resource Preloads -->
|
||||
<link rel="preload" href="/static/css/tailwind.css" as="style">
|
||||
<link rel="preload" href="/static/js/htmx.min.js" as="script">
|
||||
|
||||
<link href="/static/css/tailwind.css" rel="stylesheet">
|
||||
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
<script src="/static/js/htmx.min.js" defer></script>
|
||||
<script src="/static/js/hyperscript.min.js"></script>
|
||||
<script src="/static/js/sweetalert2@11.js" defer></script>
|
||||
<script src="/static/js/hyperscript.min.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -191,8 +192,8 @@
|
||||
</svg>
|
||||
<span class="ml-3">Endpoints</span>
|
||||
</a>
|
||||
<a hx-get="{{ url_for('settings') }}" hx-push-url="true" hx-target="#container"
|
||||
class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 group transition duration-75 flex items-center p-2 cursor-pointer {{ is_selected_page(url_for('settings')) }} page-link"
|
||||
<a hx-get="{{ url_for('settings.settings') }}" hx-push-url="true" hx-target="#container"
|
||||
class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 group transition duration-75 flex items-center p-2 cursor-pointer {{ is_selected_page(url_for('settings.settings')) }} page-link"
|
||||
_="on click add .hidden to #sidebar then remove .ml-64 from #main
|
||||
on htmx:afterRequest go to the top of the body">
|
||||
<svg class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
||||
|
||||
@@ -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,63 +85,104 @@
|
||||
</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>
|
||||
|
||||
{% 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 name in day.exercise_names %}
|
||||
<div class="truncate pl-0.5 border-l border-blue-200">{{ name }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Desktop Detailed List -->
|
||||
<div class="hidden sm:block flex-1 overflow-hidden">
|
||||
{% for workout in day.workouts %}
|
||||
<div class="bottom flex-grow py-1 w-full"
|
||||
<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 %}
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
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,19 +1,30 @@
|
||||
<tr>
|
||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 w-1/5">
|
||||
<tr class="hover:bg-gray-50/50 transition-colors group">
|
||||
<td class="p-4 text-sm font-semibold text-gray-900">
|
||||
{% if is_edit|default(false, true) == false %}
|
||||
{{ name }}
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-bold text-gray-900">{{ name }}</span>
|
||||
<!-- Mobile-only attributes -->
|
||||
<div class="flex flex-wrap gap-1 mt-1 sm:hidden">
|
||||
{% for attr in attributes %}
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
|
||||
{{ attr.attribute_name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<input
|
||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
||||
class="w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-2 px-3 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 transition-all font-normal"
|
||||
type="text" name="name" value="{{ name }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 text-sm text-gray-900 w-3/5">
|
||||
<td class="p-4 text-sm text-gray-900 hidden sm:table-cell">
|
||||
{% if is_edit|default(false, true) == false %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for attr in attributes %}
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-50 text-cyan-700 border border-cyan-100"
|
||||
title="{{ attr.category_name }}">
|
||||
{{ attr.attribute_name }}
|
||||
</span>
|
||||
@@ -22,8 +33,8 @@
|
||||
{% else %}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{% for cat_name, options in all_attributes.items() %}
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 uppercase mb-1">{{ cat_name }}</label>
|
||||
<div class="min-w-[150px]">
|
||||
<label class="block text-[10px] font-bold text-gray-400 uppercase mb-1">{{ cat_name }}</label>
|
||||
{{ render_partial('partials/custom_select.html',
|
||||
name='attribute_ids',
|
||||
options=options,
|
||||
@@ -36,12 +47,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 w-1/5 float-right">
|
||||
<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-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"
|
||||
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" />
|
||||
@@ -49,23 +62,22 @@
|
||||
<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"
|
||||
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-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"
|
||||
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>
|
||||
@@ -73,15 +85,15 @@
|
||||
</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"
|
||||
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,12 +1,24 @@
|
||||
<form class="w-full" id="new-set-workout-{{ workout_id }}"
|
||||
<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
|
||||
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">
|
||||
@@ -41,7 +53,8 @@
|
||||
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" />
|
||||
<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>
|
||||
@@ -49,20 +62,21 @@
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</form>
|
||||
|
||||
<div hx-trigger="exerciseSelected from:body"
|
||||
<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>
|
||||
hx-target="#new-set-form-container-{{ 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">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -8,12 +8,13 @@
|
||||
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 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-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"
|
||||
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" />
|
||||
@@ -21,39 +22,38 @@
|
||||
<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"
|
||||
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?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
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-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"
|
||||
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">Cancel</span>
|
||||
<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_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"
|
||||
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. -->
|
||||
@@ -46,24 +46,46 @@
|
||||
<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 -->
|
||||
</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 %}
|
||||
<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 %}>
|
||||
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 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}}
|
||||
@@ -75,122 +97,80 @@
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
<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 text-gray-900
|
||||
<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 }}"
|
||||
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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
|
||||
<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 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"
|
||||
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">
|
||||
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
|
||||
hx-trigger="change">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,22 +189,17 @@
|
||||
{% 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>
|
||||
<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>
|
||||
@@ -233,5 +208,3 @@
|
||||
</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>
|
||||
<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 class="py-2 px-4 border-b">{{ col }}</th>
|
||||
<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>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
{% for row in results %}
|
||||
<tr class="text-center">
|
||||
<tr class="hover:bg-blue-50/30 transition-colors">
|
||||
{% for col in columns %}
|
||||
<td class="py-2 px-4 border-b">{{ row[col] }}</td>
|
||||
<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 %}
|
||||
</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">
|
||||
|
||||
<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" />
|
||||
<!-- 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 SQL</span>
|
||||
<span>Copy DDL SQL</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<!-- 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,46 +1,66 @@
|
||||
<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">
|
||||
<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>
|
||||
<label for="query-title" class="block text-sm font-medium text-gray-700">Title</label>
|
||||
<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>
|
||||
</div>
|
||||
<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 %}>
|
||||
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>
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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'">
|
||||
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="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
|
||||
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" 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>
|
||||
<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>
|
||||
@@ -49,151 +69,140 @@
|
||||
</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,11 +6,12 @@
|
||||
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
|
||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
|
||||
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name }}</span>
|
||||
<div class="flex 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="#graph-content-{{ topset_id }}" hx-swap="innerHTML">
|
||||
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"
|
||||
@@ -18,6 +19,19 @@
|
||||
</svg>
|
||||
<span class="sr-only">Show Progress Graph</span>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
|
||||
title="Show History"
|
||||
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=topset_id) }}"
|
||||
hx-target="#extra-content-{{ topset_id }}" hx-swap="innerHTML">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Show History</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-full">
|
||||
@@ -103,10 +117,10 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{# Target row modified for dismissible graph #}
|
||||
<tr id="graph-target-{{ topset_id }}">
|
||||
{# Target row modified for dismissible extra content (graph or history) #}
|
||||
<tr id="extra-target-{{ topset_id }}">
|
||||
<td colspan="3" class="p-0 relative">
|
||||
<div id="graph-content-{{ topset_id }}" class="graph-content-container" _="
|
||||
<div id="extra-content-{{ topset_id }}" class="extra-content-container" _="
|
||||
on htmx:afterSwap
|
||||
get the next <button.dismiss-button/>
|
||||
if my.innerHTML is not empty and my.innerHTML is not ' '
|
||||
@@ -115,12 +129,12 @@
|
||||
add .hidden to it
|
||||
end
|
||||
end">
|
||||
<!-- Progress graph will be loaded here -->
|
||||
<!-- Progress graph or history will be loaded here -->
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-1 right-1 p-1 bg-white rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 z-10 dismiss-button hidden"
|
||||
title="Dismiss Graph" _="on click
|
||||
get #graph-content-{{ topset_id }}
|
||||
title="Dismiss Content" _="on click
|
||||
get #extra-content-{{ topset_id }}
|
||||
set its innerHTML to ''
|
||||
add .hidden to me
|
||||
end">
|
||||
@@ -129,7 +143,7 @@
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Dismiss Graph</span>
|
||||
<span class="sr-only">Dismiss Content</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
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,11 +6,18 @@
|
||||
<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>
|
||||
<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) %}
|
||||
{% if messages %}
|
||||
@@ -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.') }}
|
||||
<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>
|
||||
{# <div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
||||
Created: {{ program.created_at | strftime('%Y-%m-%d') }}
|
||||
</div> #}
|
||||
{% endfor %}
|
||||
</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">
|
||||
<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 %}
|
||||
<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>
|
||||
<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 %}
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
<input type="radio" name="settings_tabs" id="radio-users" class="peer/users hidden" checked>
|
||||
<input type="radio" name="settings_tabs" id="radio-exercises" class="peer/exercises hidden">
|
||||
<input type="radio" name="settings_tabs" id="radio-export" class="peer/export hidden">
|
||||
<input type="radio" name="settings_tabs" id="radio-activity" class="peer/activity hidden">
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 mb-6 bg-gray-50 z-10">
|
||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center text-gray-500">
|
||||
<li class="mr-2">
|
||||
<label for="radio-users"
|
||||
<label for="radio-users" hx-get="{{ url_for('settings.settings_people') }}"
|
||||
hx-target="#people-tab-content" hx-trigger="click"
|
||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||
peer-checked/users:border-cyan-600 peer-checked/users:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||
@@ -24,7 +26,8 @@
|
||||
</label>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<label for="radio-exercises"
|
||||
<label for="radio-exercises" hx-get="{{ url_for('settings.settings_exercises') }}"
|
||||
hx-target="#exercises-tab-content" hx-trigger="click"
|
||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||
peer-checked/exercises:border-cyan-600 peer-checked/exercises:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||
@@ -38,7 +41,8 @@
|
||||
</label>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<label for="radio-export"
|
||||
<label for="radio-export" hx-get="{{ url_for('settings.settings_export') }}"
|
||||
hx-target="#export-tab-content" hx-trigger="click"
|
||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||
peer-checked/export:border-cyan-600 peer-checked/export:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||
@@ -50,256 +54,60 @@
|
||||
Data & Export
|
||||
</label>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<label for="radio-activity" hx-get="{{ url_for('settings.settings_activity') }}"
|
||||
hx-target="#activity-tab-content" hx-trigger="click"
|
||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||
peer-checked/activity:border-cyan-600 peer-checked/activity:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Activity
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab Content -->
|
||||
<div class="hidden peer-checked/users:block">
|
||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">User Management</h3>
|
||||
<p class="text-sm text-gray-500">Add, edit or remove people from the tracker.</p>
|
||||
<div 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="overflow-x-auto rounded-lg">
|
||||
<div class="align-middle inline-block min-w-full">
|
||||
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col"
|
||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div class="relative max-w-xs ml-auto">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="people-search"
|
||||
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
|
||||
placeholder="Search users..."
|
||||
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200" id="new-person" hx-target="closest tr"
|
||||
hx-swap="outerHTML swap:0.5s">
|
||||
{% for p in people %}
|
||||
{{ render_partial('partials/person.html', person_id=p['PersonId'], name=p['Name'])}}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="w-full mt-6 bg-gray-50 p-4 rounded-lg border border-gray-100"
|
||||
hx-post="{{ url_for('create_person') }}" hx-swap="beforeend" hx-target="#new-person" _="on htmx:afterRequest
|
||||
render #notification-template with (message: 'User added') then append it to #notifications-container
|
||||
then call _hyperscript.processNode(#notifications-container)
|
||||
then reset() me">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div class="grow w-full sm:w-auto">
|
||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
|
||||
for="person-name">
|
||||
New user
|
||||
</label>
|
||||
<input id="person-name"
|
||||
class="appearance-none block w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-3 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
type="text" name="name" placeholder="Full Name">
|
||||
</div>
|
||||
<button
|
||||
class="w-full sm:w-auto flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-3 transition-colors shadow-sm"
|
||||
type="submit">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exercises Tab Content -->
|
||||
<div class="hidden peer-checked/exercises:block">
|
||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold text-gray-900">Exercise Configuration</h3>
|
||||
<p class="text-sm text-gray-500">Manage available exercises and their categories.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto rounded-lg">
|
||||
<div class="align-middle inline-block min-w-full">
|
||||
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col"
|
||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/2">
|
||||
Attributes
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">
|
||||
<div class="relative max-w-xs ml-auto">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="search" id="exercise-search"
|
||||
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
|
||||
placeholder="Search exercises..."
|
||||
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200" id="new-exercise"
|
||||
hx-target="closest tr" hx-swap="outerHTML swap:0.5s">
|
||||
{% for exercise in exercises %}
|
||||
{{ render_partial('partials/exercise.html', exercise_id=exercise.exercise_id,
|
||||
name=exercise.name, attributes=exercise.attributes)}}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-4">Add New Exercise</h4>
|
||||
<form class="bg-gray-50 p-6 rounded-lg border border-gray-100"
|
||||
hx-post="{{ url_for('create_exercise') }}" hx-swap="beforeend" hx-target="#new-exercise" _="on htmx:afterRequest
|
||||
render #notification-template with (message: 'Exercise added') then append it to #notifications-container
|
||||
then call _hyperscript.processNode(#notifications-container)
|
||||
then reset() me">
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||
<div class="lg:col-span-1">
|
||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
|
||||
Exercise Name
|
||||
</label>
|
||||
<input
|
||||
class="appearance-none block w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-3 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
type="text" name="name" placeholder="e.g. Bench Press">
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{% for cat_name, options in all_attributes.items() %}
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{ cat_name
|
||||
}}</label>
|
||||
{{ render_partial('partials/custom_select.html',
|
||||
name='attribute_ids',
|
||||
options=options,
|
||||
multiple=true,
|
||||
search=true,
|
||||
placeholder='Select ' ~ cat_name
|
||||
)}}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1 pt-6">
|
||||
<button
|
||||
class="w-full flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-3 transition-colors h-12 cursor-pointer shadow-sm"
|
||||
type="submit">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Add Exercise
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="hidden peer-checked/exercises:block" id="exercises-tab-content">
|
||||
<div class="flex justify-center p-12">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||
<p class="text-sm text-gray-500">Loading exercises...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Tab Content -->
|
||||
<div class="hidden peer-checked/export:block">
|
||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold text-gray-900">Data & Portability</h3>
|
||||
<p class="text-sm text-gray-500">Export your data for backup or external analysis.</p>
|
||||
<div class="hidden peer-checked/export:block" id="export-tab-content">
|
||||
<div class="flex justify-center p-12">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||
<p class="text-sm text-gray-500">Loading data settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- CSV Export -->
|
||||
<div
|
||||
class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-green-100 rounded-lg text-green-600 mr-4 shadow-sm">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v3.586l-1.293-1.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V8z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-lg font-bold text-gray-900">Workout History</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6 font-medium">Download all workout records, sets, and
|
||||
performance data in CSV format.</p>
|
||||
<a href="{{ url_for('export.export_workouts_csv') }}"
|
||||
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full shadow-sm">
|
||||
Download CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- SQL Export -->
|
||||
<div
|
||||
class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="p-3 bg-blue-100 rounded-lg text-blue-600 mr-4 shadow-sm">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-lg font-bold text-gray-900">Database Snapshot</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6 font-medium">Create a full SQL dump of your database including
|
||||
schema and all records.</p>
|
||||
<a href="{{ url_for('export.export_database_sql') }}"
|
||||
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full shadow-sm">
|
||||
Download SQL Script
|
||||
</a>
|
||||
</div>
|
||||
<!-- Activity Tab Content -->
|
||||
<div class="hidden peer-checked/activity:block" id="activity-tab-content">
|
||||
<div class="flex justify-center p-12">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||
<p class="text-sm text-gray-500">Loading activity history...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
<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 %}
|
||||
56
utils.py
56
utils.py
@@ -34,10 +34,10 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
|
||||
vb_height *= 75 / vb_height # Scale to 75px height
|
||||
|
||||
# Use NumPy arrays for efficient scaling
|
||||
relative_positions = np.array([(date - min_date).days / total_span for date in start_dates])
|
||||
estimated_1rm_scaled = ((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height
|
||||
repetitions_scaled = ((np.array(repetitions) - min_reps) / reps_range) * vb_height
|
||||
weight_scaled = ((np.array(weight) - min_weight) / weight_range) * vb_height
|
||||
relative_positions = np.round(np.array([(date - min_date).days / total_span for date in start_dates]), 1)
|
||||
estimated_1rm_scaled = np.round(((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height, 1)
|
||||
repetitions_scaled = np.round(((np.array(repetitions) - min_reps) / reps_range) * vb_height, 1)
|
||||
weight_scaled = np.round(((np.array(weight) - min_weight) / weight_range) * vb_height, 1)
|
||||
|
||||
# Calculate slope and line of best fit
|
||||
slope_kg_per_day = e1rm_range / total_span
|
||||
@@ -48,18 +48,22 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
|
||||
|
||||
best_fit_points = []
|
||||
try:
|
||||
if len(relative_positions) > 1: # Ensure there are enough points for polyfit
|
||||
# Fit a polynomial of the given degree
|
||||
coeffs = np.polyfit(relative_positions, estimated_1rm_scaled, degree)
|
||||
# Filter out NaNs if any (though scaled values shouldn't have them if ranges are correct)
|
||||
mask = ~np.isnan(estimated_1rm_scaled)
|
||||
x_fit = relative_positions[mask]
|
||||
y_fit = estimated_1rm_scaled[mask]
|
||||
|
||||
# Ensure we have enough unique X positions for the given degree
|
||||
if len(np.unique(x_fit)) > degree:
|
||||
coeffs = np.polyfit(x_fit, y_fit, degree)
|
||||
poly_fit = np.poly1d(coeffs)
|
||||
y_best_fit = poly_fit(relative_positions)
|
||||
y_best_fit = np.round(poly_fit(relative_positions), 1)
|
||||
best_fit_points = list(zip(y_best_fit.tolist(), relative_positions.tolist()))
|
||||
else:
|
||||
raise ValueError("Not enough data points for polyfit")
|
||||
except (np.linalg.LinAlgError, ValueError) as e:
|
||||
# Handle cases where polyfit fails
|
||||
best_fit_points = []
|
||||
m, b = 0, 0
|
||||
except (np.linalg.LinAlgError, ValueError, TypeError) as e:
|
||||
# Handle cases where polyfit fails or input is invalid
|
||||
best_fit_points = []
|
||||
|
||||
# Prepare data for plots
|
||||
repetitions_data = {
|
||||
@@ -254,20 +258,6 @@ def prepare_svg_plot_data(results, columns, title):
|
||||
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
|
||||
return plot_data
|
||||
|
||||
def get_client_ip():
|
||||
"""Get real client IP address, checking proxy headers first"""
|
||||
# Check common proxy headers in order of preference
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
# X-Forwarded-For can contain multiple IPs, get the first (original client)
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
elif request.headers.get('CF-Connecting-IP'): # Cloudflare
|
||||
return request.headers.get('CF-Connecting-IP')
|
||||
else:
|
||||
# Fallback to direct connection IP
|
||||
return request.remote_addr
|
||||
|
||||
# Calculate ranges (handle datetime separately)
|
||||
if x_type == 'datetime':
|
||||
valid_dates = [d for d in x_values_raw if d is not None]
|
||||
@@ -357,3 +347,17 @@ def get_client_ip():
|
||||
|
||||
|
||||
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