Files
workout/templates/partials/sparkline.html
T

333 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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" id="svg-plot-{{ unique_id }}" _="
on mouseover from .pnt-{{ unique_id }}
put (event.target's @data-kind) into #popover-kind-{{ unique_id }}
then put (event.target's @data-date) into #popover-date-{{ unique_id }}
then put (event.target's @data-e1rm) into #popover-e1rm-{{ unique_id }}
then put (event.target's @data-topset) into #popover-topset-{{ unique_id }}
then put (event.target's @data-range) into #popover-range-{{ unique_id }}
then remove .hidden from #popover-{{ unique_id }}
on mouseout from .pnt-{{ unique_id }}
add .hidden to #popover-{{ unique_id }}">
<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, 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">
<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 class="absolute right-0 z-10">
<button
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg border transition-colors {% if forecast_enabled %}text-emerald-700 bg-emerald-50 border-emerald-200 hover:bg-emerald-100{% else %}text-gray-600 bg-white border-gray-300 hover:bg-gray-50{% endif %}"
title="{% if forecast_enabled %}Hide forecast{% else %}Show forecast{% endif %}"
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) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
{% if forecast_enabled %}Forecast on{% else %}Forecast{% endif %}
</button>
</div>
</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="inline-flex rounded-md shadow-sm w-full items-center justify-center mb-1">
{% for epoch in epochs %}
<div {% if selected_epoch==epoch %}
class="px-4 py-2 text-sm font-medium text-blue-700 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"
{% 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, 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" hx-trigger="click" {% endif %}>
{% if epoch == 'Custom' %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="w-5 h-5 {% if selected_epoch == 'Custom' %}text-blue-700{% else %}text-gray-500{% endif %} group-hover:text-gray-900">
<path
d="M10 3.75a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM17.25 4.5a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM5 3.75a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 .75.75ZM4.25 17a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM17.25 17a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM9 10a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1 0-1.5h5.5A.75.75 0 0 1 9 10ZM17.25 10.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM14 10a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM10 16.25a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z" />
</svg>
{% else %}
{{ epoch}}
{% endif %}
</div>
{% endfor %}
</div>
{% if forecast_enabled %}
<div class="mx-2 mb-3 rounded-xl border {% if forecast.available %}border-emerald-100 bg-emerald-50/70{% else %}border-amber-100 bg-amber-50/70{% endif %} p-2.5 text-left">
{% if forecast.available %}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1.5">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800">Forecast · {{ forecast.horizon_weeks }} weeks</div>
<div class="text-lg font-bold text-gray-800 mt-0.5">{{ forecast.projected_value }}kg <span class="text-xs font-normal text-gray-500">projected E1RM</span></div>
</div>
<div class="text-left sm:text-right">
<div class="text-sm font-semibold {% if forecast.projected_change >= 0 %}text-emerald-700{% else %}text-rose-700{% endif %}">{% if forecast.projected_change >= 0 %}+{% endif %}{{ forecast.projected_change }}kg total</div>
<div class="text-xs text-gray-500">{{ forecast.confidence }} confidence · {{ forecast.observed_points }} sessions</div>
</div>
</div>
{% if forecast.selected_top_set %}
<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg bg-white/80 px-2.5 py-2 text-sm">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Top-set guide</span>
<span class="font-bold text-gray-800">{{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }}</span>
<span class="text-xs text-gray-500">at the selected rep target · details in History</span>
</div>
{% endif %}
<div class="flex flex-wrap items-center gap-1.5 mt-2.5">
<span class="text-xs text-gray-500 mr-1">Horizon</span>
{% for weeks, label in forecast_horizons %}
<button class="px-2 py-0.5 rounded-md text-xs font-medium {% if forecast_weeks == weeks %}bg-emerald-700 text-white{% else %}bg-white text-gray-600 border border-emerald-200 hover:bg-emerald-100{% endif %}"
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) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ label }}</button>
{% endfor %}
</div>
{% else %}
<div class="text-sm font-medium text-amber-800">Forecast unavailable</div>
<div class="text-xs text-amber-700 mt-1">{{ forecast.reason }}</div>
{% endif %}
</div>
{% 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 %}
<svg 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" stroke-dasharray="5,3" 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 }}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
{{ plot_line(plot.points, plot.color) }}
</g>
{% endfor %}
<g style="fill-opacity: 0%">
{% for detail in plot_details %}
{% set x = (detail.position * vb_width) - (stroke_width/2) + margin %}
{% set y = 0 %}
{% set width = stroke_width %}
{% set height = vb_height + margin %}
<rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}"
class="pnt-{{ unique_id }}"
data-kind="{{ detail.kind }}"
data-date="{{ detail.date }}"
data-e1rm="{{ detail.e1rm }}"
data-topset="{{ detail.top_set }}"
data-range="{{ detail.range }}"></rect>
{% endfor %}
{% if forecast_enabled and forecast.available %}
{% for detail in forecast_details %}
{% set x = (detail.position * vb_width) - (stroke_width/2) + margin %}
<rect x="{{ x | int }}" y="0" width="{{ stroke_width | int }}" height="{{ (vb_height + margin) | int }}"
class="pnt-{{ unique_id }}"
data-kind="{{ detail.kind }}"
data-date="{{ detail.date }}"
data-e1rm="{{ detail.e1rm }}"
data-topset="{{ detail.top_set }}"
data-range="{{ detail.range }}"></rect>
{% endfor %}
{% endif %}
</g>
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none"
stroke-opacity="60%" />
</svg>
<div id="popover-{{ unique_id }}" class="hidden mt-2 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm">
<div class="flex items-center justify-between gap-3 mb-1.5">
<div id="popover-kind-{{ unique_id }}" class="text-xs font-semibold uppercase tracking-wide text-emerald-700"></div>
<div class="text-right">
<div class="text-[10px] uppercase tracking-wide text-gray-400">Date</div>
<div id="popover-date-{{ unique_id }}" class="text-sm font-semibold text-gray-800"></div>
</div>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="rounded bg-gray-50 px-2 py-1.5">
<div class="text-gray-400">E1RM</div>
<div id="popover-e1rm-{{ unique_id }}" class="font-semibold text-gray-800"></div>
</div>
<div class="rounded bg-gray-50 px-2 py-1.5">
<div class="text-gray-400">Top set</div>
<div id="popover-topset-{{ unique_id }}" class="font-semibold text-gray-800"></div>
</div>
<div class="rounded bg-emerald-50 px-2 py-1.5">
<div class="text-emerald-600">Range</div>
<div id="popover-range-{{ unique_id }}" class="font-semibold text-emerald-800"></div>
</div>
</div>
</div>
<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
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>