Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Stockings
f53bf3d106 Improve monthly calendar view 2026-02-02 21:51:32 +11:00
Peter Stockings
2b330e4743 Add acheivement badges to monthly calendar view 2026-02-02 20:54:28 +11:00
Peter Stockings
bc2a350e90 Show monthly stats in calendar view 2026-02-01 10:55:28 +11:00
3 changed files with 197 additions and 58 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

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