diff --git a/rowers/forms.py b/rowers/forms.py index 8610dab5..76e6d66d 100644 --- a/rowers/forms.py +++ b/rowers/forms.py @@ -10,7 +10,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple from rowers.models import ( Workout, Rower, Team, PlannedSession, GeoCourse, VirtualRace, VirtualRaceResult, IndoorVirtualRaceResult, - PaidPlan + PaidPlan, InStrokeAnalysis ) from rowers.rows import validate_file_extension, must_be_csv, validate_image_extension, validate_kml from django.contrib.auth.forms import UserCreationForm @@ -150,6 +150,7 @@ class InstantPlanSelectForm(forms.Form): # Instroke Metrics interactive chart form class InstrokeForm(forms.Form): + name = forms.CharField(initial="", max_length=200,required=False) metric = forms.ChoiceField(label='metric',choices=(('a','a'),('b','b'))) individual_curves = forms.BooleanField(label='individual curves',initial=False, required=False) @@ -159,6 +160,9 @@ class InstrokeForm(forms.Form): required=False, initial=0, widget=forms.HiddenInput()) activeminutesmax = forms.IntegerField( required=False, initial=0, widget=forms.HiddenInput()) + notes = forms.CharField(required=False, + max_length=200, label='Notes', + widget=forms.Textarea) def __init__(self, *args, **kwargs): # pragma: no cover choices = kwargs.pop('choices', []) @@ -1246,6 +1250,13 @@ class WorkoutSingleSelectForm(forms.Form): self.fields['workout'].queryset = workouts +class InStrokeMultipleCompareForm(forms.Form): + analyses = forms.ModelMultipleChoiceField( + queryset=InStrokeAnalysis.objects.all(), + widget=forms.CheckboxSelectMultiple() + ) + + class WorkoutMultipleCompareForm(forms.Form): workouts = forms.ModelMultipleChoiceField( queryset=Workout.objects.filter(), diff --git a/rowers/interactiveplots.py b/rowers/interactiveplots.py index 063ff6f0..771a5d79 100644 --- a/rowers/interactiveplots.py +++ b/rowers/interactiveplots.py @@ -5,7 +5,7 @@ from rowers.metrics import rowingmetrics, metricsdicts from scipy.spatial import ConvexHull, Delaunay from scipy.stats import linregress, percentileofscore from pytz import timezone as tz, utc -from rowers.models import course_spline, VirtualRaceResult +from rowers.models import course_spline, VirtualRaceResult, InStrokeAnalysis from bokeh.palettes import Category20c, Category10 from bokeh.layouts import layout, widgetbox from bokeh.resources import CDN, INLINE @@ -4078,9 +4078,85 @@ def interactive_streamchart(id=0, promember=0): return [script, div] +def instroke_multi_interactive_chart(selected): + df_plot = pd.DataFrame() + ids = [analysis.id for analysis in selected] + for analysis in selected: + #start_second, end_second, spm_min, spm_max, name + activeminutesmin = int(analysis.start_second/60.) + activeminutesmax = int(analysis.end_second/60.) + rowdata = rrdata(csvfile=analysis.workout.csvfilename) + data = rowdata.get_instroke_data( + analysis.metric, + spm_min=analysis.spm_min, + spm_max=analysis.spm_max, + activeminutesmin=activeminutesmin, + activeminutesmax=activeminutesmax, + ) + mean_vals = data.mean() + xvals = np.arange(len(mean_vals)) + xname = 'x_'+str(analysis.id) + yname = 'y_'+str(analysis.id) + df_plot[xname] = xvals + df_plot[yname] = mean_vals + + source = ColumnDataSource( + df_plot + ) + + TOOLS = 'save,pan,box_zoom,wheel_zoom,reset,tap,crosshair' + plot = Figure(plot_width=920,tools=TOOLS, + toolbar_location='above', + toolbar_sticky=False) + + plot.sizing_mode = 'stretch_both' + + # add watermark + watermarkurl = "/static/img/logo7.png" + + watermarkrange = Range1d(start=0, end=1) + watermarkalpha = 0.6 + watermarkx = 0.99 + watermarky = 0.01 + watermarkw = 184 + watermarkh = 35 + watermarkanchor = 'bottom_right' + plot.extra_y_ranges = {"watermark": watermarkrange} + plot.extra_x_ranges = {"watermark": watermarkrange} + + plot.image_url([watermarkurl], watermarkx, watermarky, + watermarkw, watermarkh, + global_alpha=watermarkalpha, + w_units='screen', + h_units='screen', + anchor=watermarkanchor, + dilate=True, + x_range_name="watermark", + y_range_name="watermark", + ) + + colors = itertools.cycle(palette) + + try: + items = itertools.izip(ids, colors) + except AttributeError: + items = zip(ids, colors) + + for id, color in items: + xname = 'x_'+str(id) + yname = 'y_'+str(id) + analysis = InStrokeAnalysis.objects.get(id=id) + plot.line(xname,yname,source=source,legend_label=analysis.name, + line_width=2, color=color) + + script, div = components(plot) + + return (script, div) + def instroke_interactive_chart(df,metric, workout, spm_min, spm_max, activeminutesmin, activeminutesmax, - individual_curves): + individual_curves, + name='',notes=''): df_pos = (df+abs(df))/2. @@ -4184,6 +4260,24 @@ def instroke_interactive_chart(df,metric, workout, spm_min, spm_max, plot.add_layout(label) plot.add_layout(label2) + if name: + namelabel = Label(x=50, y=480, x_units='screen', y_units='screen', + text=name, + background_fill_alpha=0.7, + background_fill_color='white', + text_color='black', + ) + plot.add_layout(namelabel) + + if notes: + noteslabel = Label(x=50, y=50, x_units='screen', y_units='screen', + text=notes, + background_fill_alpha=0.7, + background_fill_color='white', + text_color='black', + ) + plot.add_layout(noteslabel) + if individual_curves: for index,row in df.iterrows(): plot.line(xvals,row,color='lightgray',line_width=1) diff --git a/rowers/models.py b/rowers/models.py index 2eeefe77..acfea0e0 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1378,12 +1378,20 @@ class Condition(models.Model): max_length=20, choices=conditionchoices, null=True) def __str__(self): - str = 'Condition: {metric} {condition} {value1} {value2}'.format( + str = 'Condition: {metric} {condition} {value1}'.format( metric=self.metric, condition=self.condition, value1 = self.value1, - value2 = self.value2, ) + if self.condition == 'between': + str = 'Condition: {metric} between {value1} and {value2}'.format( + metric=self.metric, + condition=self.condition, + value1 = self.value1, + value2 = self.value2, + ) + + return str @@ -1481,6 +1489,9 @@ class Alert(models.Model): value1=self.measured.value1, ) + for condition in self.filter.all(): + description += ' '+str(condition)+';' + return description def shortdescription(self): # pragma: no cover @@ -4955,3 +4966,21 @@ class ShareKey(models.Model): @property def expiration_date(self): # pragma: no cover return self.creation_date + datetime.timedelta(self.expiration_seconds) + +class InStrokeAnalysis(models.Model): + workout = models.ForeignKey(Workout, on_delete=models.CASCADE) + rower = models.ForeignKey(Rower, on_delete=models.CASCADE) + metric = models.CharField(max_length=140, blank=True, null=True) + name = models.CharField(max_length=150, blank=True, null=True) + date = models.DateField(blank=True, null=True) + notes = models.TextField(blank=True) + start_second = models.IntegerField(default=0) + end_second = models.IntegerField(default=3600) + spm_min = models.IntegerField(default=10) + spm_max = models.IntegerField(default=45) + + def __str__(self): + s = 'In-Stroke Analysis {name} ({date})'.format(name = self.name, + date = self.date) + + return s diff --git a/rowers/tasks.py b/rowers/tasks.py index bff93d3e..01f5bc7b 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -106,6 +106,7 @@ NK_API_LOCATION = CFG["nk_api_location"] TP_CLIENT_ID = CFG["tp_client_id"] TP_CLIENT_SECRET = CFG["tp_client_secret"] + from requests_oauthlib import OAuth1, OAuth1Session import pandas as pd @@ -386,7 +387,7 @@ def instroke_static(w, metric, debug=False, **kwargs): @app.task def handle_request_post(url, data, debug=False, **kwargs): # pragma: no cover if 'localhost' in url: - url = 'http'+url[5:] + url = 'http'+url[4:] response = requests.post(url, data, verify=False) dologging('upload_api.log', data) dologging('upload_api.log', response.status_code) @@ -1715,6 +1716,7 @@ def handle_sendemail_breakthrough(workoutid, useremail, tablevalues = [ {'delta': t.delta, + 'time': str(timedelta(seconds=t.delta)), 'cpvalue': t.cpvalues, 'pwr': t.pwr } for t in btvalues.itertuples() diff --git a/rowers/templates/analysis.html b/rowers/templates/analysis.html index b1e8ea47..555f58bf 100644 --- a/rowers/templates/analysis.html +++ b/rowers/templates/analysis.html @@ -131,18 +131,6 @@

Need to monitor a metric? Set up automatic alerting and see the reports for your workouts.

- -
  • -

    Ranking Pieces

    - -
    - Ranking Piece -
    -
    -

    - Analyze your Concept2 ranking pieces over a date range and predict your pace on other pieces. -

  • Histogram

    diff --git a/rowers/templates/breakthroughemail.html b/rowers/templates/breakthroughemail.html index beadc132..d4280b5c 100644 --- a/rowers/templates/breakthroughemail.html +++ b/rowers/templates/breakthroughemail.html @@ -40,7 +40,7 @@ {% for set in btvalues %} - {{ set["delta"] }} + {{ set["time"] }} {{ set["cpvalue"] }} {{ set["pwr"] }} diff --git a/rowers/templates/instroke_analysis.html b/rowers/templates/instroke_analysis.html new file mode 100644 index 00000000..c63273d3 --- /dev/null +++ b/rowers/templates/instroke_analysis.html @@ -0,0 +1,94 @@ +{% extends "newbase.html" %} +{% load static %} +{% load rowerfilters %} + +{% block title %}Rowsandall - Analysis {% endblock %} + +{% block main %} + +{{ js_res | safe }} +{{ css_res| safe }} + + + + + +{{ the_script |safe }} + +

    In-Stroke Analysis for {{ rower.user.first_name }} {{ rower.user.last_name }}

    + +
    + +{% csrf_token %} + +
    + + + +{% endblock %} + +{% block scripts %} +{% endblock %} + +{% block sidebar %} +{% include 'menu_analytics.html' %} +{% endblock %} diff --git a/rowers/templates/instroke_interactive.html b/rowers/templates/instroke_interactive.html index db6aaa5d..7eec7665 100644 --- a/rowers/templates/instroke_interactive.html +++ b/rowers/templates/instroke_interactive.html @@ -143,7 +143,8 @@ $( function() {

    - +

    +

  • diff --git a/rowers/templates/instrokeanalysis_delete_confirm.html b/rowers/templates/instrokeanalysis_delete_confirm.html new file mode 100644 index 00000000..9af367be --- /dev/null +++ b/rowers/templates/instrokeanalysis_delete_confirm.html @@ -0,0 +1,29 @@ +{% extends "newbase.html" %} +{% load static %} + +{% block title %}In-Stroke Analysis{% endblock %} + +{% block main %} +

    Confirm Delete

    +

    This will permanently delete the analysis

    + + + + + +{% endblock %} + +{% block sidebar %} +{% include 'menu_analytics.html' %} +{% endblock %} diff --git a/rowers/templates/laboratory.html b/rowers/templates/laboratory.html index 15b27e02..8eeab9c3 100644 --- a/rowers/templates/laboratory.html +++ b/rowers/templates/laboratory.html @@ -12,7 +12,7 @@

    Rower: {{ rower.user.first_name }}

    - Try out Alerts + Try out In-Stroke Analysis

    {% endblock %} diff --git a/rowers/templates/menu_analytics.html b/rowers/templates/menu_analytics.html index 2ad76f72..03bde62e 100644 --- a/rowers/templates/menu_analytics.html +++ b/rowers/templates/menu_analytics.html @@ -51,11 +51,6 @@  Marker Workouts -
  • -
  • - -  Ranking Pieces -
  • diff --git a/rowers/tests/test_async_tasks.py b/rowers/tests/test_async_tasks.py index daa642e6..43718b36 100644 --- a/rowers/tests/test_async_tasks.py +++ b/rowers/tests/test_async_tasks.py @@ -306,6 +306,7 @@ class AsyncTaskTests(TestCase): btvalues = pd.DataFrame({ 'delta':[3,1,3], + 'time': str(timedelta(seconds=t) for t in [3,1,3]), 'cpvalues':[100,200,300], 'pwr':[100,200,300] }).to_json() diff --git a/rowers/urls.py b/rowers/urls.py index 51364854..e73b05a7 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -252,6 +252,10 @@ urlpatterns = [ path('403/', TemplateView.as_view(template_name='403.html'), name='403'), re_path(r'^workout/(?P\b[0-9A-Fa-f]+\b)/instroke/interactive/$', views.instroke_chart_interactive, name='instroke_chart_interactive'), + re_path(r'^workout/(?P\b[0-9A-Fa-f]+\b)/instroke/interactive/(?P\d+)/$', + views.instroke_chart_interactive, name='instroke_chart_interactive'), + re_path(r'^workout/(?P\b[0-9A-Fa-f]+\b)/instroke/interactive/(?P\d+)/user/(?P\d+)/$', + views.instroke_chart_interactive, name='instroke_chart_interactive'), re_path(r'^exportallworkouts/?/$', views.workouts_summaries_email_view, name='workouts_summaries_email_view'), path('failedjobs/', views.failed_queue_view, name='failed_queue_view'), @@ -828,6 +832,10 @@ urlpatterns = [ re_path(r'^errormessage/(?P[\w\ ]+.*)/$', views.errormessage_view, name='errormessage_view'), re_path(r'^analysis/$', views.analysis_view, name='analysis'), + re_path(r'^analysis/instrokeanalysis/$', views.instrokeanalysis_view, + name='instrokeanalysis_view'), + re_path(r'^analysis/instrokeanalysis/(?P\d+)/delete/$', + views.InStrokeAnalysisDelete.as_view(), name='instroke_analysis_delete_view'), re_path(r'^promembership', TemplateView.as_view( template_name='promembership.html'), name='promembership'), re_path(r'^checkout/(?P\d+)/$', diff --git a/rowers/views/analysisviews.py b/rowers/views/analysisviews.py index 66f96a62..83b496a7 100644 --- a/rowers/views/analysisviews.py +++ b/rowers/views/analysisviews.py @@ -1845,6 +1845,105 @@ def agegrouprecordview(request, sex='male', weightcategory='hwt', }) +@user_passes_test(ispromember, login_url="/rowers/paidplans", + message="This functionality requires a Pro plan or higher." + " If you are already a Pro user, please log in to access this functionality", + redirect_field_name=None) +@permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True) +def instrokeanalysis_view(request, userid=0): + r = getrequestrower(request, userid=userid) + + analyses = InStrokeAnalysis.objects.filter(rower=r).order_by("-date","-id") + + script = "" + div = "" + + if request.method == 'POST': + form = InStrokeMultipleCompareForm(request.POST) + + if form.is_valid(): + cd = form.cleaned_data + selected = cd['analyses'] + request.session['analyses'] = [a.id for a in selected] + # now should redirect to analysis + script, div = instroke_multi_interactive_chart(selected) + + breadcrumbs = [ + { + 'url': '/rowers/analysis', + 'name': 'Analysis' + }, + { + 'url': reverse('instrokeanalysis_view'), + 'name': 'In-Stroke Analysis', + }, + ] + + return render(request, 'instroke_analysis.html', + { + 'breadcrumbs': breadcrumbs, + 'analyses': analyses, + 'rower': r, + 'the_script': script, + 'the_div': div, + }) + +#instroke analysis delete view +class InStrokeAnalysisDelete(DeleteView): + login_required = True + model = InStrokeAnalysis + template_name = 'instrokeanalysis_delete_confirm.html' + + # extra parameters + def get_context_data(self, **kwargs): + context = super(InStrokeAnalysisDelete, self).get_context_data(**kwargs) + + if 'userid' in kwargs: # pragma: no cover + userid = kwargs['userid'] + else: + userid = 0 + + context['rower'] = getrequestrower(self.request, userid=userid) + context['alert'] = self.object + + breadcrumbs = [ + { + 'url': '/rowers/analysis', + 'name': 'Analysis' + }, + { + 'url': reverse('instrokeanalysis_view'), + 'name': 'In-Stroke Analysis', + }, + { + 'url': reverse('instroke_chart_interactive', + kwargs={'userid': userid, + 'id': encoder.encode_hex(self.object.workout.id), + 'analysis': self.object.pk}), + 'name': self.object.name, + }, + { + 'url': reverse('instroke_analysis_delete_view', kwargs={'pk': self.object.pk}), + 'name': 'Delete' + } + ] + + context['breadcrumbs'] = breadcrumbs + + return context + + def get_success_url(self): + return reverse('instrokeanalysis_view') + + def get_object(self, *args, **kwargs): + obj = super(InStrokeAnalysisDelete, self).get_object(*args, **kwargs) + + if obj.rower != self.request.user.rower: + raise PermissionDenied("You are not allowed to delete this Analysis") + + return obj + + @login_required @permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True) def alerts_view(request, userid=0): @@ -1877,8 +1976,6 @@ def alerts_view(request, userid=0): }) # alert create view - - @user_passes_test(ispromember, login_url="/rowers/paidplans", message="This functionality requires a Pro plan or higher." " If you are already a Pro user, please log in to access this functionality", @@ -2133,8 +2230,6 @@ def alert_edit_view(request, id=0, userid=0): }) # alert delete view - - class AlertDelete(DeleteView): login_required = True model = Alert diff --git a/rowers/views/racesviews.py b/rowers/views/racesviews.py index 3ced8b20..4e926444 100644 --- a/rowers/views/racesviews.py +++ b/rowers/views/racesviews.py @@ -1894,20 +1894,24 @@ def virtualevent_addboat_view(request, id=0): followers = VirtualRaceFollower.objects.filter(race=race) - for follower in followers: + for follower in followers: othername = '' if follower.user: othername = follower.user.first_name+' '+follower.user.last_name registeredname = r.user.first_name+' '+r.user.last_name email = follower.emailaddress - if follower.user.id not in registereduserids: - _ = myqueue( - queue, - handle_sendemail_raceregistration, - email, othername, - registeredname, race.name, race.id, - ) + try: + if follower.user.id not in registereduserids: + _ = myqueue( + queue, + handle_sendemail_raceregistration, + email, othername, + registeredname, race.name, race.id, + ) + registereduserids.append(follower.user.id) + except AttributeError: + pass url = reverse('virtualevent_view', kwargs={ diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 01709aec..4a1e3f01 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -111,7 +111,7 @@ from rowers.forms import ( VideoAnalysisMetricsForm, SurveyForm, HistorySelectForm, StravaChartForm, FitnessFitForm, PerformanceManagerForm, TrainingPlanBillingForm, InstantPlanSelectForm, - TrainingZonesForm, InstrokeForm + TrainingZonesForm, InstrokeForm, InStrokeMultipleCompareForm ) from django.urls import reverse, reverse_lazy @@ -153,7 +153,7 @@ from rowers.models import ( VideoAnalysis, ShareKey, StandardCollection, CourseStandard, VirtualRaceFollower, TombStone, InstantPlan, - PlannedSessionStep, + PlannedSessionStep,InStrokeAnalysis, ) from rowers.models import ( RowerPowerForm, RowerHRZonesForm, RowerForm, RowerCPForm, GraphImage, AdvancedWorkoutForm, diff --git a/rowers/views/workoutviews.py b/rowers/views/workoutviews.py index a3155e52..469a6d4f 100644 --- a/rowers/views/workoutviews.py +++ b/rowers/views/workoutviews.py @@ -2915,11 +2915,17 @@ def instroke_chart(request, id=0, metric=''): # pragma: no cover return HttpResponseRedirect(url) +@user_passes_test(ispromember, login_url="/rowers/paidplans", + message="This functionality requires a Pro plan or higher." + " If you are already a Pro user, please log in to access this functionality", + redirect_field_name=None) @permission_required('workout.change_workout', fn=get_workout_by_opaqueid, raise_exception=True) -def instroke_chart_interactive(request, id=0): +def instroke_chart_interactive(request, id=0, analysis=0, userid=0): is_ajax = request_is_ajax(request) + r = getrequestrower(request, userid=userid) + w = get_workoutuser(id, request) rowdata = rrdata(csvfile=w.csvfilename) @@ -2975,17 +2981,64 @@ def instroke_chart_interactive(request, id=0): 'maxminutes': maxminutes, }) + if analysis: + try: + instroke_analysis = InStrokeAnalysis.objects.get(id=analysis) + if instroke_analysis.rower != r: + analysis = 0 + messages.error(request,'Access to this saved analysis denied') + raise ValueError + if instroke_analysis.workout != w: + messages.error(request,'This saved analysis belongs to a different workout') + form = InstrokeForm( + choices=instrokemetrics, + initial={ + 'metric':instroke_analysis.metric, + 'name': instroke_analysis.name, + 'notes': instroke_analysis.notes, + 'activeminutesmin':int(instroke_analysis.start_second/60.), + 'activeminutesmax':int(instroke_analysis.end_second/60.), + 'spm_min': instroke_analysis.spm_min, + 'spm_max': instroke_analysis.spm_max, + } + ) + metric = instroke_analysis.metric + name = instroke_analysis.name + notes = instroke_analysis.notes + activeminutesmin = int(instroke_analysis.start_second/60.) + activeminutesmax = int(instroke_analysis.end_second/60.) + spm_min = instroke_analysis.spm_min + spm_max = instroke_analysis.spm_max + except (InStrokeAnalysis.DoesNotExist, ValueError): + metric = instrokemetrics[0] + spm_min = 15 + spm_max = 45 + name = '' + notes = '' + activeminutesmax = int(rowdata.duration/60.) + activeminutesmin = 0 + + else: + + metric = instrokemetrics[0] + + spm_min = 15 + spm_max = 45 + name = '' + notes = '' + + activeminutesmax = int(rowdata.duration/60.) + activeminutesmin = 0 + + maxminutes = int(rowdata.duration/60.) + individual_curves = False + + + script = '' div = get_call() - metric = instrokemetrics[0] - spm_min = 15 - spm_max = 45 - activeminutesmax = int(rowdata.duration/60.) - activeminutesmin = 0 - maxminutes = activeminutesmax - individual_curves = False if request.method == 'POST': form = InstrokeForm(request.POST,choices=instrokemetrics) @@ -2996,6 +3049,37 @@ def instroke_chart_interactive(request, id=0): activeminutesmin = form.cleaned_data['activeminutesmin'] activeminutesmax = form.cleaned_data['activeminutesmax'] individual_curves = form.cleaned_data['individual_curves'] + notes = form.cleaned_data['notes'] + name = form.cleaned_data['name'] + + if "_save" in request.POST: + if not analysis: + instroke_analysis = InStrokeAnalysis( + workout = w, + metric = metric, + name = name, + date = timezone.now().date(), + notes = notes, + start_second = 60*activeminutesmin, + end_second = 60*activeminutesmax, + spm_min = spm_min, + spm_max = spm_max, + rower=w.user, + ) + else: + instroke_analysis.workout = w + instroke_analysis.metric = metric + instroke_analysis.name = name + instroke_analysis.date = timezone.now().date() + instroke_analysis.notes = notes + instroke_analysis.start_second = 60*activeminutesmin + instroke_analysis.end_second = 60*activeminutesmax + instroke_analysis.spm_min = spm_min + instroke_analysis.spm_max = spm_max + instroke_analysis.rower=w.user + + instroke_analysis.save() + messages.info(request,'In-Stroke Analysis saved') activesecondsmin = 60.*activeminutesmin @@ -3016,6 +3100,7 @@ def instroke_chart_interactive(request, id=0): activeminutesmin, activeminutesmax, individual_curves, + name=name,notes=notes, ) # change to range spm_min to spm_max diff --git a/static/css/rowsandall2.css b/static/css/rowsandall2.css index a157f4ae..b58cf043 100644 --- a/static/css/rowsandall2.css +++ b/static/css/rowsandall2.css @@ -395,6 +395,14 @@ th.rotate > div > span { margin: 0px; } +.analysiscontainer { + display: grid; + grid-template-columns: 50px repeat(auto-fit, minmax(calc((100% - 100px)/7), 1fr)); + /* grid-template-columns: 50px repeat(auto-fit, minmax(100px, 1fr)) 50px; ????*/ + padding: 5px; + margin: 0px; +} + .workoutelement { margin-left: auto; margin-right: auto;