Files
workout/templates/partials/sparkline.html
T

279 lines
18 KiB
HTML

{% set stroke_width = 4 %}
{% 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 %}
{% 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 %}
{% endmacro %}
{% macro path_band(lower_points, upper_points, vb_height) %}
{% if lower_points and upper_points %}
M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | int) }} {{ (((vb_height - value) + margin) | int) }}{% if not loop.last %} L{% endif %}{% endfor %}
{% for value, position in upper_points|reverse %} L{{ (((position * vb_width) + margin) | int) }} {{ (((vb_height - value) + margin) | int) }}{% endfor %} Z
{% endif %}
{% 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 %}
{% endmacro %}
{% macro forecast_circles(data_points, details, color) %}
{% for value, position in data_points %}
{% if loop.last %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1.6" fill="{{ color }}" stroke="{{ color }}"
class="pnt-{{ unique_id }}"
data-kind="{{ details[loop.index0].kind }}"
data-date="{{ details[loop.index0].date }}"
data-e1rm="{{ details[loop.index0].e1rm }}"
data-topset="{{ details[loop.index0].top_set }}"
data-range="{{ details[loop.index0].range }}"></circle>
{% endif %}
{% endfor %}
{% endmacro %}
{% macro plot_line(points, color) %}
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
{{ circles(points, color) }}
{% endmacro %}
<!-- HubSpot doesn't escape whitespace. -->
{% macro random_int() %}{% for n in [0,1,2,3,4,5] %}{{ [0,1,2,3,4,5,6,7,8,9]|random }}{% endfor %}{% endmacro %}
<!-- You have to manually call the macros in a list. -->
{% set parts = [random_int()] %}
{% set unique_id = parts|join('-') %}
<div class="relative exercise-progress" id="svg-plot-{{ unique_id }}">
<div class="progress-heading">
<div class="progress-title">
<h4>{{ title }}</h4>
</div>
<div class="progress-actions">
<button class="progress-action" title="History and forecast tables"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=latest_topset_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps, epoch=selected_epoch, min_date=min_date, max_date=max_date) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">Table</button>
<button class="progress-action {% if forecast_enabled %}is-active{% endif %}"
aria-pressed="{{ 'true' if forecast_enabled else 'false' }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='false' if forecast_enabled else 'true', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
{% if selected_epoch == 'Custom' %}hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"{% endif %}
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">Forecast {{ 'on' if forecast_enabled else 'off' }}</button>
</div>
</div>
<div class="progress-badges" aria-label="Progress achievements" 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"></div>
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
{% if best_fit_formula and not forecast_enabled %}
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
{% endif %}
</h2>
<div class="progress-epochs" aria-label="Visible history">
<span class="forecast-caption">History</span>
{% for epoch in epochs %}
<button class="forecast-horizon {% if selected_epoch == epoch %}is-active{% endif %}"
aria-pressed="{{ 'true' if selected_epoch == epoch else 'false' }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=epoch, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ epoch }}</button>
{% endfor %}
</div>
{% if forecast_enabled %}
<section class="forecast-summary" aria-label="Forecast summary">
{% if forecast.available %}
{% set next = forecast.next_session %}
<div class="forecast-summary-grid">
<div>
<div class="forecast-eyebrow">{{ 'Historical forecast' if forecast.stale else 'Likely next session' }}</div>
<div class="forecast-primary">{{ next.set_display }}</div>
<div class="forecast-caption">Around {{ next.date.strftime('%d %b %Y') }} · {{ next.value }} kg est. 1RM</div>
</div>
<div class="forecast-horizon-summary">
<div class="forecast-eyebrow">{{ forecast.horizon_weeks }}-week scenario</div>
<div class="forecast-secondary">{{ forecast.projected_value }} <span>kg est. 1RM</span></div>
<div class="forecast-caption">By {{ forecast.end_date.strftime('%d %b %Y') }}</div>
</div>
</div>
<div class="forecast-caption forecast-assumption">
{% if forecast.profile.increment and forecast.profile.ceiling_evidence >= 2 %}
Your pattern: build toward {{ forecast.profile.ceiling }} reps, then +{{ '%g'|format(forecast.profile.increment) }} kg.
{% else %}
Learning from your recent repeats, rep changes and load changes.
{% endif %}
{% if forecast.stale %}Based on an older training block; log new sessions to update.{% endif %}
</div>
<div class="forecast-controls">
<span class="forecast-caption">Look ahead</span>
{% for weeks, label in forecast_horizons %}
<button class="forecast-horizon {% if forecast_weeks == weeks %}is-active{% endif %}"
aria-pressed="{{ 'true' if forecast_weeks == weeks else 'false' }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='true', forecast_weeks=weeks, forecast_reps=forecast_target_reps, min_date=min_date, max_date=max_date) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ label }}</button>
{% endfor %}
</div>
{% if forecast.long_range %}<p class="forecast-caption forecast-assumption">Long-range scenario, assuming a similar routine. Exact sets and dates become less certain.</p>{% endif %}
{% else %}
<div class="forecast-eyebrow">Learning your pattern</div>
<p class="forecast-caption">{{ forecast.reason }}</p>
{% endif %}
</section>
{% endif %}
{% if selected_epoch == 'Custom' %}
<div class="flex flex-col md:flex-row justify-center pb-4">
<!-- Min Date -->
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
Min date
</label>
<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
2 0 002 2h12a2 2 0 002-2V6a2 2 0
00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1
1 0 00-1-1zm0 5a1 1 0 000
2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
</div>
<input type="date" class="bg-gray-50 border border-gray-300 text-gray-900
text-sm rounded-lg focus:ring-blue-500
focus:border-blue-500 block w-full pl-10 p-2.5" name="min_date" value="{{ min_date }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-include="#svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
</div>
</div>
<!-- Max Date -->
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Max date
</label>
<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 2 0 002 2h12a2
2 0 002-2V6a2 2 0 00-2-2h-1V3a1
1 0 10-2 0v1H7V3a1 1 0
00-1-1zm0 5a1 1 0 000
2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
</div>
<input type="date" class="bg-gray-50 border border-gray-300
text-gray-900 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 }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
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">
</div>
</div>
<!-- Degree -->
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
Degree
</label>
<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>
</div>
<input type="number"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 w-full"
name="degree" value="{{ degree }}" min="1" step="1"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
hx-trigger="change">
</div>
</div>
</div>
{% endif %}
{% if forecast.available %}<div class="forecast-caption">Estimated 1RM · kg</div>{% endif %}
<div class="forecast-plot">
{% if forecast.available %}<div class="forecast-y-axis" aria-hidden="true">{% for value in e1rm_scale %}<span>{{ value }}</span>{% endfor %}</div>{% endif %}
<svg class="forecast-chart" tabindex="0" role="group" aria-label="Exercise history and forecast. Use left and right arrows to inspect dates." viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none">
{% if forecast_enabled and forecast.available %}
<path d="{{ path_band(forecast_band_lower, forecast_band_upper, vb_height) }}" fill="#10b981" fill-opacity="0.10" stroke="none"></path>
<path d="{{ path(forecast_points, vb_height) }}" stroke="#059669" stroke-width="2" vector-effect="non-scaling-stroke" stroke-dasharray="5,4" fill="none"></path>
{{ forecast_circles(forecast_points, forecast_details, '#059669') }}
<line x1="{{ ((forecast_start_position * vb_width) + margin) | int }}" y1="{{ margin }}" x2="{{ ((forecast_start_position * vb_width) + margin) | int }}" y2="{{ vb_height + margin }}" stroke="#059669" stroke-width="0.8" stroke-dasharray="2,2" stroke-opacity="0.5"></line>
{% endif %}
{% for plot in plots %}
<g class="{{ plot.label }} {% if forecast.available and plot.label != 'E1RM' %}hidden{% endif %}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
{{ plot_line(plot.points, plot.color) }}
</g>
{% endfor %}
<line class="forecast-cursor" x1="{{ default_detail.position * vb_width + margin }}" x2="{{ default_detail.position * vb_width + margin }}" y1="0" y2="{{ vb_height + margin }}" stroke="#64748b" stroke-width="0.5" stroke-dasharray="2,2" pointer-events="none"/>
<g fill="transparent">
{% for detail in hover_details %}
<rect x="{{ detail.hit_start * vb_width + margin }}" y="0"
width="{{ (detail.hit_end - detail.hit_start) * vb_width }}" height="{{ vb_height + margin }}"
class="forecast-hit"
data-position="{{ detail.position * vb_width + margin }}"
data-kind="{{ detail.kind }}" data-date="{{ detail.date }}"
data-e1rm="{{ detail.e1rm }}" data-topset="{{ detail.top_set }}" data-range="{{ detail.range }}">
<title>{{ detail.date }} · {{ detail.top_set }} · {{ detail.e1rm }} E1RM</title>
</rect>
{% endfor %}
</g>
{% if not forecast_enabled %}
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none" stroke-opacity="60%" pointer-events="none"/>
{% endif %}
</svg>
</div>
<div class="forecast-axis"><span>{{ min_date.strftime('%d %b %Y') }}</span><span>{{ (forecast.end_date if forecast.available else max_date).strftime('%d %b %Y') }}</span></div>
<div class="forecast-readout" aria-label="Selected chart reading">
<div class="forecast-readout-heading"><span data-readout="kind">{{ default_detail.kind }}</span><time data-readout="date">{{ default_detail.date }}</time></div>
<div class="forecast-readout-values">
<div><span class="forecast-caption">Top set</span><strong data-readout="topset">{{ default_detail.top_set }}</strong></div>
<div><span class="forecast-caption">Est. 1RM</span><strong data-readout="e1rm">{{ default_detail.e1rm }}</strong></div>
<div><span class="forecast-caption">E1RM scenario range</span><strong data-readout="range">{{ default_detail.range }}</strong></div>
</div>
</div>
<p class="forecast-chart-hint">Hover or tap to inspect · focus chart and use ← → for dates{% if forecast.available %} · shaded area: plausible scenarios{% endif %}</p>
<div class="flex flex-wrap justify-center pt-2">
{% for plot in plots %}
<div class="flex items-center px-2 select-none cursor-pointer {% if forecast.available and plot.label != 'E1RM' %}line-through{% endif %}" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
on click toggle .hidden on my.plot_line then toggle .line-through on me">
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
<div class="text-xs">{{ plot.label }}</div>
</div>
{% endfor %}
{% if forecast_enabled and forecast.available %}
<div class="flex items-center px-2 select-none">
<div class="w-3 h-0.5 mr-1 border-t-2 border-dashed border-emerald-600"></div>
<div class="text-xs">Projected E1RM</div>
</div>
{% endif %}
</div>
</div>