Reorganize workout view for desktop and compact session details

This commit is contained in:
Peter Stockings
2026-09-06 17:10:06 +10:00
parent 1aa5d7418b
commit d5e3e1d930
9 changed files with 345 additions and 183 deletions
+1
View File
@@ -15,6 +15,7 @@
<link href="/static/css/tailwind.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet">
<link href="/static/css/workout.css?v=1" rel="stylesheet">
<script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/hyperscript.min.js" defer></script>
<link rel="stylesheet" href="/static/css/forecast.css?v=2">
+16 -14
View File
@@ -2,7 +2,7 @@
<form class="w-full" id="new-set-workout-{{ workout_id }}"
hx-post="{{ url_for('workout.create_topset', person_id=person_id, workout_id=workout_id) }}" hx-swap="beforeend"
hx-target="#new-workout" _="on htmx:afterOnLoad
if #no-workouts add .hidden to #no-workouts end
if detail.xhr.status == 200
set #validation-error-{{ workout_id }}.innerText to ''
add .hidden to #validation-error-{{ workout_id }}
@@ -19,16 +19,16 @@
<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">
<div class="workout-set-fields">
<div class="workout-set-field">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="exercise-search">
Exercise
</label>
{{ render_partial('partials/exercise/exercise_select.html', person_id=person_id,
exercise_id=exercise_id, exercise_name=exercise_name) }}
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<div class="workout-set-field">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
Reps
</label>
@@ -38,9 +38,9 @@
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
<div class="workout-set-field">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Weight
Weight (kg)
</label>
<input
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
@@ -48,14 +48,15 @@
_="on clearNewSetInputs set my.placeholder to ''" {% endif %}>
</div>
<div class="w-full md:w-[10%] px-2 md:px-3 mb-6 md:mb-0">
<div class="workout-set-submit">
<button
class="flex items-center justify-center py-2 px-2 md:px-3 mb-3 text-sm font-medium text-center text-gray-900 bg-cyan-600 hover:bg-cyan-700 rounded-lg border border-gray-300 hover:scale-[1.02] transition-transform mb-6 md:mb-0 mt-0 md:mt-6 w-full"
class="workout-add-button"
type="submit">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-7 h-7">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
<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>
<span>Add set</span>
</button>
</div>
@@ -66,17 +67,18 @@
<div hx-trigger="exerciseSelected from:body"
hx-get="{{ url_for('workout.get_most_recent_topset_for_exercise', person_id=person_id, workout_id=workout_id) }}"
hx-target="#new-set-form-container-{{ workout_id }}" hx-include="[name='exercise_id']">
hx-target="#new-set-form-container-{{ workout_id }}" hx-swap="outerHTML" hx-include="[name='exercise_id']">
</div>
{% if exercise_id %}
<div class="flex items-center justify-center">
<details class="workout-previous-details">
<summary>Previous performance{% if repetitions %}<span>{{ repetitions }} &times; {{ weight }} kg</span>{% endif %}</summary>
<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">
hx-trigger="toggle once from:closest details" hx-target="this" hx-swap="outerHTML">
</div>
</div>
</div>
</details>
{% endif %}
</div>
+8 -8
View File
@@ -1,12 +1,12 @@
<tr id="topset-{{ topset_id }}">
<tr class="workout-set-row" id="topset-{{ topset_id }}">
<td class="p-0 sm:p-4 text-sm font-semibold text-gray-900 break-normal">
{% if is_edit|default(false, true) == false %}
<div class="flex items-center space-x-2">
<div class="workout-exercise-name">
<span class="cursor-pointer" hx-get="{{ url_for('tags.goto_tag') }}"
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">
<div class="workout-exercise-tools">
<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"
@@ -17,7 +17,7 @@
<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>
<span class="workout-tool-label">Progress</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"
@@ -29,7 +29,7 @@
<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>
<span class="workout-tool-label">History</span>
</button>
</div>
</div>
@@ -57,11 +57,11 @@
<div class="flex items-center flex-col sm:flex-row">
<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-4 sm:pl-10 p-2.5 "
name="repetitions" value="{{ repetitions }}">
aria-label="Repetitions" name="repetitions" value="{{ repetitions }}">
<p class="px-2">x</p>
<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-4 sm:pl-10 p-2.5 "
name="weight" value="{{ weight }}">
aria-label="Weight in kilograms" step="any" name="weight" value="{{ weight }}">
<p class="px-2">kg</p>
</div>
{% endif %}
@@ -118,7 +118,7 @@
</td>
</tr>
{# Target row modified for dismissible extra content (graph or history) #}
<tr id="extra-target-{{ topset_id }}">
<tr id="extra-target-{{ topset_id }}" {% if existing_row|default(false) or is_edit|default(false) %}hx-swap-oob="outerHTML"{% endif %}>
<td colspan="3" class="p-0 relative">
<div id="extra-content-{{ topset_id }}" class="extra-content-container" _="
on htmx:afterSwap
+12 -58
View File
@@ -1,64 +1,18 @@
{% set distribution = distribution or muscle_distribution %}
{% set category_name = category_name or 'Muscle Group' %}
{% set distribution = distribution|default([], true) %}
{% set category_name = category_name|default('Muscle Group', true) %}
{% set breakdown_id = category_name.lower().replace(' ', '-') %}
{% if distribution %}
<div class="px-4 py-3 bg-white relative" id="{{ breakdown_id }}-breakdown"
<div class="workout-breakdown" id="{{ breakdown_id }}-breakdown"
hx-get="{{ url_for('workout.get_workout_distribution', person_id=person_id, workout_id=workout_id, category=category_name) }}"
hx-trigger="topsetAdded from:body" hx-swap="outerHTML">
<!-- Shared Popover Container (managed by Hyperscript) -->
<div id="popover-{{ breakdown_id }}"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 hidden bg-white border border-gray-200 shadow-2xl rounded-xl p-4 min-w-[200px] animate-in fade-in zoom-in duration-200"
_="on click from elsewhere add .hidden to me">
<div id="content-{{ breakdown_id }}"></div>
<button class="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
_="on click add .hidden to #popover-{{ breakdown_id }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
hx-trigger="topsetAdded from:body, topsetChanged from:body" hx-swap="outerHTML">
<h3>{{ category_name }}</h3>
{% if distribution %}
<div class="workout-distribution-bar" aria-hidden="true">
{% for item in distribution %}<span style="width: {{ item.percentage }}%; background-color: {{ item.color }};"></span>{% endfor %}
</div>
<div class="flex items-center justify-between mb-2">
<h4 class="text-[9px] font-black text-gray-400 uppercase tracking-[0.25em]">{{ category_name }} Distribution
</h4>
</div>
<!-- Sleek Performance Bar -->
<div class="w-full h-8 flex overflow-hidden rounded-lg bg-gray-100 shadow-inner p-0.5 cursor-pointer">
<ul class="workout-distribution-legend">
{% for item in distribution %}
<div class="h-full transition-all duration-700 ease-in-out flex items-center justify-center relative group first:rounded-l-md last:rounded-r-md"
style="width: {{ item.percentage }}%; background-color: {{ item.color }};" _="on click
halt the event
put '<div class=\'flex flex-col gap-1\'>
<div class=\'flex items-center gap-2\'>
<div class=\'w-3 h-3 rounded-full\' style=\'background-color: {{ item.color }}\'></div>
<span class=\'font-black text-sm uppercase\'>{{ item.attribute_name }}</span>
</div>
<div class=\'text-2xl font-black text-gray-900\'>{{ item.percentage }}%</div>
<div class=\'text-[10px] font-bold text-gray-400 uppercase tracking-wider\'>{{ item.count }} Sets Targeted</div>
</div>' into #content-{{ breakdown_id }}
remove .hidden from #popover-{{ breakdown_id }}"
title="{{ item.attribute_name }}: {{ item.percentage }}%">
<!-- Labels (Name & Percentage grouped and centered) -->
<div
class="flex items-center justify-center gap-1.5 leading-none text-white pointer-events-none px-1 overflow-hidden">
{% if item.percentage > 18 %}
<span class="text-[9px] font-black uppercase tracking-tighter truncate drop-shadow-sm">{{
item.attribute_name }}</span>
<span class="text-[9px] font-bold opacity-90 whitespace-nowrap drop-shadow-sm">{{ item.percentage
}}%</span>
{% elif item.percentage > 8 %}
<span class="text-[9px] font-black drop-shadow-sm">{{ item.percentage }}%</span>
{% endif %}
</div>
<!-- Enhanced Hover State -->
<div class="absolute inset-0 bg-white/15 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<li><span class="workout-legend-label"><i style="background-color: {{ item.color }};" aria-hidden="true"></i>{{ item.attribute_name }}</span><span title="{{ item.count }} sets targeted">{{ item.percentage }}%</span></li>
{% endfor %}
</div>
</ul>
{% else %}<p class="workout-subtle">No categorized sets yet.</p>{% endif %}
</div>
{% endif %}
+14
View File
@@ -0,0 +1,14 @@
{% set totals = namespace(reps=0) %}
{% for top_set in top_sets %}{% set totals.reps = totals.reps + (top_set.repetitions or 0)|int %}{% endfor %}
<section class="workout-card workout-summary" id="workout-summary"
aria-label="Session summary" aria-live="polite"
hx-get="{{ url_for('workout.get_workout_summary', person_id=person_id, workout_id=workout_id) }}"
hx-trigger="topsetAdded from:body, topsetChanged from:body" hx-swap="outerHTML">
<p class="workout-eyebrow">This session</p>
<dl>
<div><dt>Exercises</dt><dd>{{ top_sets|map(attribute='exercise_id')|unique|list|length }}</dd></div>
<div><dt>Top sets</dt><dd>{{ top_sets|length }}</dd></div>
<div><dt>Reps</dt><dd>{{ totals.reps }}</dd></div>
</dl>
<p class="workout-subtle">Based on your logged top sets.</p>
</section>
+69 -100
View File
@@ -1,9 +1,8 @@
{% extends 'base.html' %}
{% block content %}
<nav class="flex md:pb-0 pb-4" aria-label="Breadcrumb">
<div class="workout-workspace">
<nav class="flex md:pb-0 pb-4" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse">
<li class="inline-flex items-center">
<a hx-get="{{ url_for('dashboard') }}" hx-push-url="true" hx-target="#container"
@@ -23,8 +22,8 @@
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m1 9 4-4-4-4" />
</svg>
{% set hx_current_url = request.headers.get('HX-Current-URL') %}
<a hx-get="{{ hx_current_url if hx_current_url else url_for('calendar.get_calendar', person_id=person_id) }}"
<a hx-get="{{ url_for('calendar.get_calendar', person_id=person_id) }}"
hx-push-url="true" hx-target="#container"
class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2 dark:text-gray-400 dark:hover:text-white cursor-pointer">{{person_name}}</a>
</div>
@@ -41,107 +40,77 @@
</li>
</ol>
</nav>
<header class="workout-header">
<div>
<p class="workout-eyebrow">Training log</p>
<h1>{{ person_name }}<span> / Workout</span></h1>
{{ render_partial('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date) }}
</div>
<details class="workout-menu">
<summary class="workout-button">Session options <span aria-hidden="true">&middot;&middot;&middot;</span></summary>
<div class="workout-menu-content">
<button type="button" class="workout-delete"
hx-get="{{ url_for('workout.delete_workout', person_id=person_id, workout_id=workout_id) }}"
hx-confirm="Are you sure you wish to delete this workout?" hx-push-url="true" hx-target="#container">Delete workout</button>
</div>
</details>
</header>
<div class='p-0 md:p-4 m-0 md:m-2'>
<div class="relative w-full h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow overflow-hidden">
<!-- Modal header -->
<div class="p-4 md:p-6 border-b relative">
<div class="flex flex-col w-full pr-8">
<h3 class="text-2xl font-black text-gray-900 leading-tight">{{ person_name }}</h3>
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id,
tags=tags) }}
<div class="workout-layout">
<div class="workout-main">
<section class="workout-card workout-logger" aria-labelledby="log-heading">
<div class="workout-section-heading">
<div><p class="workout-eyebrow">Log your training</p><h2 id="log-heading">Add a top set</h2></div>
<span class="workout-subtle">Reps &times; weight</span>
</div>
{{ render_partial('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, has_value=False) }}
</section>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{{ render_partial('partials/start_date.html', person_id=person_id, workout_id=workout_id,
start_date=start_date) }}
{{ render_partial('partials/workout_note.html', person_id=person_id, workout_id=workout_id,
note=note) }}
</div>
<section class="workout-card workout-results" aria-labelledby="sets-heading">
<div class="workout-section-heading">
<div><h2 id="sets-heading">Session exercises</h2><p class="workout-subtle">Your top sets, records and progress.</p></div>
</div>
<table class="workout-table">
<thead><tr><th scope="col">Exercise</th><th scope="col">Top set</th><th scope="col"><span class="sr-only">Actions</span></th></tr></thead>
<tbody id="new-workout" hx-target="closest tr" hx-swap="outerHTML swap:0.5s">
{% for top_set in top_sets %}
{{ render_partial('partials/topset.html', person_id=person_id, workout_id=workout_id, **top_set) }}
{% endfor %}
</tbody>
</table>
<div class="workout-empty" id="no-workouts"><strong>Your session starts here</strong><p>Add your first top set above. Progress and records will appear here.</p></div>
<div id="exercise-progress"></div>
</section>
</div>
<button type="button"
class="absolute top-4 right-4 text-gray-400 bg-transparent hover:bg-gray-100 hover:text-gray-900 rounded-lg text-sm p-2 transition-colors inline-flex items-center"
hx-get="{{ url_for('workout.delete_workout', person_id=person_id, workout_id=workout_id) }}"
hx-confirm="Are you sure you wish to delete this workout?" hx-push-url="true"
hx-target="#container">
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg>
<span class="sr-only">Delete workout</span>
</button>
</div>
<!-- Multi-Category Breakdown Stripes -->
<div class="bg-gray-50/50 border-b divide-y divide-gray-100">
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
distribution=muscle_distribution, category_name='Muscle Group') }}
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
distribution=equipment_distribution, category_name='Machine vs Free Weight') }}
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
distribution=movement_distribution, category_name='Compound vs Isolation') }}
</div>
<!-- Workout Content Card -->
<div class="p-0">
<div class="p-2 md:p-4 border-0 md:border-b">
<!-- Modal footer / New Set Form -->
<div class="flex items-center p-1 md:p-2 rounded-b">
{{ render_partial('partials/new_set_form.html', person_id=person_id,
workout_id=workout_id,
exercises=exercises,
has_value=False) }}
</div>
<!-- Modal body / Top Sets Table -->
<div class="p-3 md:p-6 space-y-6">
<table class="items-center w-full bg-transparent border-collapse table-fixed">
<thead>
<tr>
<th
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
Exercise</th>
<th
class="px-4 bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap">
Top Set</th>
<th
class="bg-gray-50 text-gray-700 align-middle py-3 text-xs font-semibold text-left uppercase border-l-0 border-r-0 whitespace-nowrap w-9 sm:w-20">
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100" id="new-workout" hx-target="closest tr"
hx-swap="outerHTML swap:0.5s">
{% for top_set in top_sets %}
{{ render_partial('partials/topset.html', person_id=person_id,
workout_id=workout_id, **top_set) }}
{% endfor %}
</tbody>
</table>
{% if top_sets|length == 0 %}
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert"
id="no-workouts">
No sets recorded for this session.
</div>
{% endif %}
</div>
<div id="exercise-progress" class="mx-0 md:mx-5">
</div>
<aside class="workout-sidebar" aria-label="Session information">
{{ render_partial('partials/workout_summary.html', person_id=person_id, workout_id=workout_id, top_sets=top_sets) }}
<details class="workout-card workout-disclosure" open>
<summary>Training balance <span class="workout-subtle">Muscles & equipment</span></summary>
<div class="workout-disclosure-body">
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id, distribution=muscle_distribution, category_name='Muscle Group') }}
<details class="workout-secondary-details">
<summary>Equipment & movement</summary>
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id, distribution=equipment_distribution, category_name='Machine vs Free Weight') }}
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id, distribution=movement_distribution, category_name='Compound vs Isolation') }}
</details>
</div>
</div>
</div>
</details>
<details class="workout-card workout-disclosure" {% if note %}open{% endif %}>
<summary>Session notes <span class="workout-subtle">Cues, energy, how it felt</span></summary>
<div class="workout-disclosure-body">
{{ render_partial('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note or '') }}
</div>
</details>
<details class="workout-card workout-disclosure">
<summary>Tags <span class="workout-subtle">Organize this session</span></summary>
<div class="workout-disclosure-body">
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id, tags=tags) }}
</div>
</details>
</aside>
</div>
</div>
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load"
hx-target="#stats" hx-swap="innerHTML">
</div>
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load" hx-target="#stats" hx-swap="innerHTML"></div>
{% endblock %}