From 4473f06bd2ab3795b791eb3ad96f3ae6792e18ac Mon Sep 17 00:00:00 2001 From: rustybrooks Date: Mon, 11 Nov 2024 16:06:54 -0600 Subject: [PATCH] Get training plans basically working --- src/api/bikes/models/season.py | 61 +++ src/api/bikes/models/strava_activity.py | 4 +- .../bikes/models/strava_activity_segment.py | 6 +- src/api/bikes/models/strava_segment.py | 8 +- .../bikes/models/strava_segment_history.py | 3 + src/api/bikes/models/training.py | 28 +- src/api/bikes/plans/__init__.py | 25 +- src/api/bikes/settings.py | 1 - src/api/bikes/urls.py | 12 +- .../views/{activities.py => activity.py} | 0 src/api/bikes/views/season.py | 62 +++ src/api/bikes/views/seasons.py | 126 ----- src/api/bikes/views/training_entry.py | 42 ++ src/api/bikes/views/training_week.py | 63 +++ src/api/bikes/views/{users.py => user.py} | 0 src/ui/src/App.tsx | 18 +- src/ui/src/api/DTOs.ts | 493 +++++++++++++----- src/ui/src/api/api-fetch.ts | 2 + src/ui/src/components/Calendar.tsx | 115 ++-- src/ui/src/components/FixedModal.tsx | 18 + src/ui/src/components/Header.tsx | 7 +- src/ui/src/utils/test.ts | 11 + src/ui/src/views/Home.tsx | 15 +- src/ui/src/views/TrainingPlansManage.tsx | 63 ++- 24 files changed, 798 insertions(+), 385 deletions(-) rename src/api/bikes/views/{activities.py => activity.py} (100%) create mode 100644 src/api/bikes/views/season.py delete mode 100644 src/api/bikes/views/seasons.py create mode 100644 src/api/bikes/views/training_entry.py create mode 100644 src/api/bikes/views/training_week.py rename src/api/bikes/views/{users.py => user.py} (100%) create mode 100644 src/ui/src/components/FixedModal.tsx create mode 100644 src/ui/src/utils/test.ts diff --git a/src/api/bikes/models/season.py b/src/api/bikes/models/season.py index a61ff6a..2573587 100644 --- a/src/api/bikes/models/season.py +++ b/src/api/bikes/models/season.py @@ -1,6 +1,11 @@ +import datetime +from typing import Optional + from django.contrib.auth.models import User from django.db import models +from bikes.plans import CTBv1 + class Season(models.Model): # LIMIT_FACTOR_CHOICES = ( @@ -59,6 +64,62 @@ def zone_power(self, zone): return int(self.ftp_watts * zones[zone]) + @staticmethod + def generate_weeks( + user: Optional[User], + season_start_date: datetime.date, + season_end_date: datetime.date, + params, + ): + from bikes.models import TrainingWeek + + ctb = CTBv1(params) + progression = ctb.progression() + + weeks: list[TrainingWeek] = [] + + season = Season( + user=user, + season_start_date=season_start_date, + season_end_date=season_end_date, + training_plan="CTB", + params=params, + ) + + entries = [] + this_day = season_start_date or datetime.date.today() + prog_index = 0 + prog_ct = 1 + week_prog = progression[prog_index] + while season_start_date: + if prog_ct > week_prog[1]: + prog_ct = 1 + prog_index += 1 + + if prog_index >= len(progression): + break + + if season_end_date and this_day > season_end_date: + break + + week_prog = progression[prog_index] + + w = TrainingWeek( + season=season, + week_start_date=this_day, + week_type=week_prog[0], + week_type_num=prog_ct, + ) + entries.extend(w.populate_entries(save=False)) + weeks.append(w) + + next_day = this_day + datetime.timedelta(days=7) + + this_day = next_day + prog_ct += 1 + + return [season, weeks, entries] + # def entries(self): # entries = [] # for entry in ( diff --git a/src/api/bikes/models/strava_activity.py b/src/api/bikes/models/strava_activity.py index 727af4e..0010af8 100644 --- a/src/api/bikes/models/strava_activity.py +++ b/src/api/bikes/models/strava_activity.py @@ -243,10 +243,10 @@ def update_curves(cls): StravaSpeedCurve, # type: ignore ) - all = cls.objects.filter() + all_activities = cls.objects.filter() updated = 0 - for activity in all: + for activity in all_activities: streams1 = StravaPowerCurve.objects.filter(activity=activity) streams2 = StravaSpeedCurve.objects.filter(activity=activity) if len(streams1) and len(streams2): diff --git a/src/api/bikes/models/strava_activity_segment.py b/src/api/bikes/models/strava_activity_segment.py index f671172..602dd03 100644 --- a/src/api/bikes/models/strava_activity_segment.py +++ b/src/api/bikes/models/strava_activity_segment.py @@ -33,13 +33,13 @@ def sync_one(cls, activity, segment): # logger.info("sync one segment effort id=%r", segment["id"]) - id = segment["id"] - segs = cls.objects.filter(activity_segment_id=id) + segment_id = segment["id"] + segs = cls.objects.filter(activity_segment_id=segment_id) if len(segs): sege = segs[0] else: sege = StravaActivitySegmentEffort() - sege.activity_segment_id = id + sege.activity_segment_id = segment_id sege.activity = activity sege.start_datetime = segment.get("start_date") diff --git a/src/api/bikes/models/strava_segment.py b/src/api/bikes/models/strava_segment.py index 508d24f..c68cbde 100644 --- a/src/api/bikes/models/strava_segment.py +++ b/src/api/bikes/models/strava_segment.py @@ -34,9 +34,11 @@ class StravaSegment(models.Model): @classmethod def sync_one(cls, segment): - id = segment["id"] - segs = StravaSegment.objects.filter(segment_id=id) - seg: Self = cast(Self, segs[0] if len(segs) else StravaSegment(segment_id=id)) + segment_id = segment["id"] + segs = StravaSegment.objects.filter(segment_id=segment_id) + seg: Self = cast( + Self, segs[0] if len(segs) else StravaSegment(segment_id=segment_id) + ) for key in [ "resource_state", diff --git a/src/api/bikes/models/strava_segment_history.py b/src/api/bikes/models/strava_segment_history.py index 4073d47..6bd056f 100644 --- a/src/api/bikes/models/strava_segment_history.py +++ b/src/api/bikes/models/strava_segment_history.py @@ -79,6 +79,9 @@ def sync_one(cls, user, segment_id, athlete_id): def sync_all(cls, user, athlete_id): import time + from bikes.libs import stravaapi + from bikes.models import StravaSegment + segments = StravaSegment.objects.filter() for s in segments: try: diff --git a/src/api/bikes/models/training.py b/src/api/bikes/models/training.py index 5c97029..c00b58a 100644 --- a/src/api/bikes/models/training.py +++ b/src/api/bikes/models/training.py @@ -17,12 +17,6 @@ def tp_from_season(s): - # logger.error( - # "tp_from_season - season = %r, params = %r, tp = %r", - # s, - # s.params, - # s.training_plan, - # ) return tpmap[s.training_plan](s.params) @@ -42,8 +36,7 @@ def __unicode__(self): return "%s:%s-%s" % (self.week_start_date, self.week_type, self.week_type_num) def json(self, cal): - output: dict[str, Any] = {} - output["entries"] = [] + output: dict[str, Any] = {"entries": []} for elist in cal.entries_by_week(self): output["entries"].append([e.json() for e in elist]) @@ -126,7 +119,7 @@ class TrainingEntry(models.Model): "Season", unique_for_date="entry_date", on_delete=models.DO_NOTHING ) week = models.ForeignKey(TrainingWeek, on_delete=models.DO_NOTHING) - workout_type = models.CharField(max_length=50) # , choices=NAME_CHOICES + workout_type = models.CharField(max_length=50) activity_type = models.CharField(max_length=50) scheduled_dow = models.IntegerField() scheduled_length = models.FloatField() @@ -137,23 +130,6 @@ class TrainingEntry(models.Model): def __unicode__(self): return "%s" % (self.entry_date,) - def json(self): - output = {} - - for el in ( - "id", - "entry_date", - "workout_type", - "scheduled_dow", - "scheduled_length", - "actual_length", - ): - output[el] = getattr(self, el) - - output["workout_types"] = self.workout_type_list() - - return output - def workout_type_list(self): tp = tp_from_season(self.season) return tp.workout_types(self) diff --git a/src/api/bikes/plans/__init__.py b/src/api/bikes/plans/__init__.py index a1b3837..af8d29e 100755 --- a/src/api/bikes/plans/__init__.py +++ b/src/api/bikes/plans/__init__.py @@ -12,7 +12,8 @@ def __init__(self, params): class TCC(TrainingPlan): - def progression(self): + @classmethod + def progression(cls): return tccdata.progression def plan_entries(self, week): @@ -51,21 +52,25 @@ def plan_entries(self, week): return entries - def weekly_hours(self, week): + @classmethod + def weekly_hours(cls, _week): # pass return 7 - def workout_description(self, wo_type): + @classmethod + def workout_description(cls, _wo_type): return "temp" - def workout_types(self, entry): + @classmethod + def workout_types(cls, entry): key = "%s" % (entry.week.week_type,) patterns = tccdata.workout_patterns[key] return patterns[entry.scheduled_dow] class CTBv1(TrainingPlan): - def progression(self): + @classmethod + def progression(cls): return tbv1data.progresssion def weekly_hours(self, week): @@ -81,7 +86,8 @@ def weekly_hours(self, week): return hours - def plan_entries(self, week): + @classmethod + def plan_entries(cls, week): try: key = "%s-%s" % (week.week_type, week.week_type_num) patterns = tbv1data.workout_patterns[key] @@ -112,16 +118,19 @@ def plan_entries(self, week): activity_type="Ride", # FIXME if we add other types scheduled_dow=dow_ind, scheduled_length=week_hours[hpatterns[dow_ind] - 1], + scheduled_length2=0, actual_length=week_hours[hpatterns[dow_ind] - 1], ) ) return entries - def workout_description(self, wo_type): + @classmethod + def workout_description(cls, wo_type): return tbv1data.workouts[wo_type] - def workout_types(self, entry): + @classmethod + def workout_types(cls, entry): try: key = "%s-%s" % (entry.week.week_type, entry.week.week_type_num) patterns = tbv1data.workout_patterns[key] diff --git a/src/api/bikes/settings.py b/src/api/bikes/settings.py index b305b82..a707147 100644 --- a/src/api/bikes/settings.py +++ b/src/api/bikes/settings.py @@ -199,7 +199,6 @@ def __call__(self, request): "rest_framework.parsers.MultiPartParser", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework.authentication.BasicAuthentication", "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ diff --git a/src/api/bikes/urls.py b/src/api/bikes/urls.py index 53a8c56..a2e5992 100644 --- a/src/api/bikes/urls.py +++ b/src/api/bikes/urls.py @@ -4,15 +4,21 @@ from django.urls import include, path, re_path # type: ignore from rest_framework.routers import SimpleRouter # type: ignore -from bikes.views.activities import ActivitiesViewSet # type: ignore -from bikes.views.seasons import SeasonViewSet # type: ignore +from bikes.views.activity import ActivitiesViewSet # type: ignore +from bikes.views.season import SeasonViewSet # type: ignore from bikes.views.swagger import SwaggerView # type: ignore -from bikes.views.users import UserViewSet # type: ignore +from bikes.views.training_entry import TrainingEntryViewSet +from bikes.views.training_week import TrainingWeekViewSet +from bikes.views.user import UserViewSet # type: ignore router = SimpleRouter() router.register(r"api/seasons", SeasonViewSet, basename="season") router.register(r"api/users", UserViewSet, basename="user") router.register(r"api/activities", ActivitiesViewSet, basename="activity") +router.register( + r"api/training_entries", TrainingEntryViewSet, basename="training_entry" +) +router.register(r"api/training_weeks", TrainingWeekViewSet, basename="training_week") urlpatterns = [ path("health/", include("health_check.urls")), diff --git a/src/api/bikes/views/activities.py b/src/api/bikes/views/activity.py similarity index 100% rename from src/api/bikes/views/activities.py rename to src/api/bikes/views/activity.py diff --git a/src/api/bikes/views/season.py b/src/api/bikes/views/season.py new file mode 100644 index 0000000..8f4e122 --- /dev/null +++ b/src/api/bikes/views/season.py @@ -0,0 +1,62 @@ +import logging + +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import serializers, status # type: ignore +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet # type: ignore + +from bikes import models, plans # type: ignore +from bikes.models import Season +from bikes.views.training_entry import TrainingEntryOut + +logger = logging.getLogger(__name__) + + +class SeasonSerializer(serializers.ModelSerializer): + class Meta: + model = Season + exclude: list[str] = [] + + +class TrainingBibleV1In(serializers.Serializer): + season_start_date = serializers.DateField(allow_null=True) + season_end_date = serializers.DateField(allow_null=True) + params = serializers.JSONField() + + +class TrainingBiblePreviewOut(serializers.Serializer): + entries = serializers.ListField(child=TrainingEntryOut()) + hour_selection = serializers.ListField(child=serializers.IntegerField()) + + +class SeasonViewSet(ModelViewSet): + queryset = Season.objects.all() + serializer_class = SeasonSerializer + ordering_fields = ["season_start_date", "season_end_date"] + + @swagger_auto_schema( + request_body=TrainingBibleV1In, + responses={200: openapi.Response("", TrainingBiblePreviewOut(many=False))}, + ) + @action(detail=False, methods=["post"]) + def preview_training_bible_v1(self, request: Request): + serializer = TrainingBibleV1In(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + season, weeks, entries = Season.generate_weeks( + user=None, **serializer.validated_data + ) + + training_serialized = TrainingEntryOut(data=entries, many=True) + training_serialized.is_valid() + training_entries_data = training_serialized.data + + data = { + "entries": training_entries_data, + "hour_selection": plans.training_bible_v1.annual_hours_lookup["Yearly"], + } + return Response(data) diff --git a/src/api/bikes/views/seasons.py b/src/api/bikes/views/seasons.py deleted file mode 100644 index bb78c88..0000000 --- a/src/api/bikes/views/seasons.py +++ /dev/null @@ -1,126 +0,0 @@ -import datetime -import logging - -from drf_yasg import openapi -from drf_yasg.utils import swagger_auto_schema -from rest_framework import serializers # type: ignore -from rest_framework.decorators import action -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet # type: ignore - -from bikes import models, plans # type: ignore -from bikes.models import Season, TrainingEntry, TrainingWeek -from bikes.plans import CTBv1 - -logger = logging.getLogger(__name__) - - -class SeasonSerializer(serializers.ModelSerializer): - class Meta: - model = Season - exclude: list[str] = [] - - -class TrainingBibleV1In(serializers.Serializer): - season_start_date = serializers.DateField() - season_end_date = serializers.DateField() - annual_hours = serializers.IntegerField() - - -class TrainingEntryOut(serializers.ModelSerializer): - class Meta: - model = TrainingEntry - exclude: list[str] = [] - depth = 2 - - workout_types = serializers.DictField() - - -class TrainingBiblePreviewOut(serializers.Serializer): - entries = serializers.ListField(child=TrainingEntryOut()) - hour_selection = serializers.ListField(child=serializers.IntegerField()) - - -class SeasonViewSet(ModelViewSet): - queryset = Season.objects.all() - serializer_class = SeasonSerializer - ordering_fields = ["season_start_date", "season_end_date"] - - @swagger_auto_schema( - request_body=TrainingBibleV1In, - responses={200: openapi.Response("", TrainingBiblePreviewOut(many=False))}, - ) - @action(detail=False, methods=["post"]) - def preview_training_bible_v1(self, request: Request): - ctb = CTBv1({"annual_hours": request.data["annual_hours"]}) - progression = ctb.progression() - - weeks: list[TrainingWeek] = [] - start_date = ( - datetime.date.fromisoformat(request.data["season_start_date"]) - if request.data["season_start_date"] - else None - ) - end_date = ( - datetime.date.fromisoformat(request.data["season_end_date"]) - if request.data["season_end_date"] - else None - ) - logger.info("start=%r end=%r", start_date, end_date) - - season = Season( - season_start_date=start_date, - training_plan="CTB", - params={"annual_hours": request.data["annual_hours"]}, - ) - - entries = [] - this_day = start_date or datetime.date.today() - prog_index = 0 - prog_ct = 1 - week_prog = progression[prog_index] - while start_date: - if prog_ct > week_prog[1]: - prog_ct = 1 - prog_index += 1 - - if prog_index >= len(progression): - break - - if end_date and this_day > end_date: - break - - week_prog = progression[prog_index] - - w = TrainingWeek( - season=season, - week_start_date=this_day, - week_type=week_prog[0], - week_type_num=prog_ct, - ) - entries.extend(w.populate_entries(save=False)) - weeks.append(w) - - next_day = this_day + datetime.timedelta(days=7) - - this_day = next_day - prog_ct += 1 - - for to in entries: - to.workout_types = { - wt: ctb.workout_description(wt) for wt in to.workout_type_list() - } - - training_serialized = TrainingEntryOut(data=entries, many=True) - training_serialized.is_valid() - training_entries_data = training_serialized.data - - data = { - "entries": training_entries_data, - "hour_selection": plans.training_bible_v1.annual_hours_lookup["Yearly"], - } - logger.info("data %r", data) - serializer = TrainingBiblePreviewOut(data, many=False) - logger.info("serialized %r", serializer.data) - return Response(data) diff --git a/src/api/bikes/views/training_entry.py b/src/api/bikes/views/training_entry.py new file mode 100644 index 0000000..718de2b --- /dev/null +++ b/src/api/bikes/views/training_entry.py @@ -0,0 +1,42 @@ +from rest_framework import serializers +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet + +from bikes.models import TrainingEntry +from bikes.plans import CTBv1 +from bikes.views.activity import range_filters + + +class TrainingEntryOut(serializers.ModelSerializer): + class Meta: + model = TrainingEntry + exclude: list[str] = [] + depth = 0 + + workout_types = serializers.SerializerMethodField(allow_null=False) + + @classmethod + def get_workout_types(cls, obj) -> dict: + return {wt: CTBv1.workout_description(wt) for wt in obj.workout_type_list()} + + +class TrainingEntryViewSet(ModelViewSet): + queryset = TrainingEntry.objects.all() + serializer_class = TrainingEntryOut + permission_classes = [IsAuthenticated] + + filterset_fields = { + "week__id": ["exact"], + "workout_type": ["exact"], + "activity_type": ["exact"], + "scheduled_dow": ["exact"], + "scheduled_length": ["exact"], + "actual_length": ["exact"], + "week__week_start_date": range_filters, + } + search = [] + ordering_fields = [ + "week", + "workout_type", + "activity_type", + ] diff --git a/src/api/bikes/views/training_week.py b/src/api/bikes/views/training_week.py new file mode 100644 index 0000000..6ec7cf1 --- /dev/null +++ b/src/api/bikes/views/training_week.py @@ -0,0 +1,63 @@ +import datetime + +from django.db import transaction +from drf_yasg.utils import swagger_auto_schema +from rest_framework import serializers, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from bikes.models import Season, TrainingEntry, TrainingWeek +from bikes.views.activity import range_filters +from bikes.views.training_entry import TrainingEntryOut + + +class TrainingWeekOut(serializers.ModelSerializer): + class Meta: + model = TrainingWeek + exclude: list[str] = [] + depth = 0 + + +class TrainingWeekPopulateIn(serializers.Serializer): + season_start_date = serializers.DateField(allow_null=True) + season_end_date = serializers.DateField(allow_null=True) + training_plan = serializers.CharField() + params = serializers.JSONField() + + +class TrainingWeekViewSet(ModelViewSet): + queryset = TrainingWeek.objects.all() + serializer_class = TrainingWeekOut + permission_classes = [IsAuthenticated] + + filterset_fields = {"week_start_date": range_filters} + search = [] + ordering_fields = [] + + @swagger_auto_schema( + request_body=TrainingWeekPopulateIn, responses={200: TrainingWeekOut} + ) + @action(detail=False, methods=["post"]) + @transaction.atomic + def populate(self, request: Request): + serializer = TrainingWeekPopulateIn(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + season, weeks, entries = Season.generate_weeks( + user=request.user, + season_start_date=serializer.validated_data["season_start_date"], + season_end_date=serializer.validated_data["season_end_date"], + params=serializer.validated_data["params"], + ) + season.save() + TrainingWeek.objects.bulk_create(weeks) + TrainingEntry.objects.bulk_create(entries) + + out_ser = TrainingEntryOut(data=entries, many=True) + out_ser.is_valid() + + return Response(out_ser.data) diff --git a/src/api/bikes/views/users.py b/src/api/bikes/views/user.py similarity index 100% rename from src/api/bikes/views/users.py rename to src/api/bikes/views/user.py diff --git a/src/ui/src/App.tsx b/src/ui/src/App.tsx index 2b17402..fef6489 100644 --- a/src/ui/src/App.tsx +++ b/src/ui/src/App.tsx @@ -167,16 +167,14 @@ export const App = () => { -
- - } /> - } /> - } /> - } /> - } /> - } /> - -
+ + } /> + } /> + } /> + } /> + } /> + } /> +
diff --git a/src/ui/src/api/DTOs.ts b/src/ui/src/api/DTOs.ts index 4be5a1e..84b2ec8 100644 --- a/src/ui/src/api/DTOs.ts +++ b/src/ui/src/api/DTOs.ts @@ -182,21 +182,21 @@ export interface TrainingBibleV1In { * Season start date * @format date */ - season_start_date: string; + season_start_date?: string | null; /** * Season end date * @format date */ - season_end_date: string; - /** Annual hours */ - annual_hours: number; + season_end_date?: string | null; + /** Params */ + params: object; } export interface TrainingEntryOut { /** ID */ id?: number; /** Workout types */ - workout_types: Record; + workout_types?: object; /** * Entry date * @format date @@ -232,134 +232,10 @@ export interface TrainingEntryOut { * @maxLength 2000 */ notes: string; - season?: { - /** ID */ - id?: number; - /** Training plan */ - training_plan: 'CTB' | 'TCC'; - /** - * Season start date - * @format date - */ - season_start_date: string; - /** - * Season end date - * @format date - */ - season_end_date: string; - /** Params */ - params: object; - user?: { - /** ID */ - id?: number; - /** - * Password - * @minLength 1 - * @maxLength 128 - */ - password: string; - /** - * Last login - * @format date-time - */ - last_login?: string | null; - /** - * Superuser status - * Designates that this user has all permissions without explicitly assigning them. - */ - is_superuser?: boolean; - /** - * Username - * Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. - * @minLength 1 - * @maxLength 150 - * @pattern ^[\w.@+-]+$ - */ - username: string; - /** - * First name - * @maxLength 150 - */ - first_name?: string; - /** - * Last name - * @maxLength 150 - */ - last_name?: string; - /** - * Email address - * @format email - * @maxLength 254 - */ - email?: string; - /** - * Staff status - * Designates whether the user can log into this admin site. - */ - is_staff?: boolean; - /** - * Active - * Designates whether this user should be treated as active. Unselect this instead of deleting accounts. - */ - is_active?: boolean; - /** - * Date joined - * @format date-time - */ - date_joined?: string; - /** - * The groups this user belongs to. A user will get all permissions granted to each of their groups. - * @uniqueItems true - */ - groups?: number[]; - /** - * Specific permissions for this user. - * @uniqueItems true - */ - user_permissions?: number[]; - }; - }; - week?: { - /** ID */ - id?: number; - /** - * Week start date - * @format date - */ - week_start_date: string; - /** - * Week type - * @minLength 1 - * @maxLength 50 - */ - week_type: string; - /** - * Week type num - * @min -2147483648 - * @max 2147483647 - */ - week_type_num: number; - season?: { - /** ID */ - id?: number; - /** Training plan */ - training_plan: 'CTB' | 'TCC'; - /** - * Season start date - * @format date - */ - season_start_date: string; - /** - * Season end date - * @format date - */ - season_end_date: string; - /** Params */ - params: object; - /** User */ - user: number; - }; - }; + /** Season */ + season: number; + /** Week */ + week: number; } export interface TrainingBiblePreviewOut { @@ -367,6 +243,50 @@ export interface TrainingBiblePreviewOut { hour_selection: number[]; } +export interface TrainingWeekOut { + /** ID */ + id?: number; + /** + * Week start date + * @format date + */ + week_start_date: string; + /** + * Week type + * @minLength 1 + * @maxLength 50 + */ + week_type: string; + /** + * Week type num + * @min -2147483648 + * @max 2147483647 + */ + week_type_num: number; + /** Season */ + season?: number | null; +} + +export interface TrainingWeekPopulateIn { + /** + * Season start date + * @format date + */ + season_start_date?: string | null; + /** + * Season end date + * @format date + */ + season_end_date?: string | null; + /** + * Training plan + * @minLength 1 + */ + training_plan: string; + /** Params */ + params: object; +} + export interface User { /** ID */ id?: number; @@ -1025,6 +945,313 @@ export class Api extends HttpClient + this.request< + { + count: number; + /** @format uri */ + next?: string | null; + /** @format uri */ + previous?: string | null; + results: TrainingEntryOut[]; + }, + any + >({ + path: `/training_entries/`, + method: 'GET', + query: query, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_entries + * @name TrainingEntriesCreate + * @request POST:/training_entries/ + * @secure + */ + trainingEntriesCreate: (data: TrainingEntryOut, params: RequestParams = {}) => + this.request({ + path: `/training_entries/`, + method: 'POST', + body: data, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_entries + * @name TrainingEntriesRead + * @request GET:/training_entries/{id}/ + * @secure + */ + trainingEntriesRead: (id: number, params: RequestParams = {}) => + this.request({ + path: `/training_entries/${id}/`, + method: 'GET', + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_entries + * @name TrainingEntriesUpdate + * @request PUT:/training_entries/{id}/ + * @secure + */ + trainingEntriesUpdate: (id: number, data: TrainingEntryOut, params: RequestParams = {}) => + this.request({ + path: `/training_entries/${id}/`, + method: 'PUT', + body: data, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_entries + * @name TrainingEntriesPartialUpdate + * @request PATCH:/training_entries/{id}/ + * @secure + */ + trainingEntriesPartialUpdate: (id: number, data: TrainingEntryOut, params: RequestParams = {}) => + this.request({ + path: `/training_entries/${id}/`, + method: 'PATCH', + body: data, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_entries + * @name TrainingEntriesDelete + * @request DELETE:/training_entries/{id}/ + * @secure + */ + trainingEntriesDelete: (id: number, params: RequestParams = {}) => + this.request({ + path: `/training_entries/${id}/`, + method: 'DELETE', + secure: true, + ...params, + }), + }; + trainingWeeks = { + /** + * No description + * + * @tags training_weeks + * @name TrainingWeeksList + * @request GET:/training_weeks/ + * @secure + */ + trainingWeeksList: ( + query?: { + /** week_start_date */ + week_start_date?: string; + /** week_start_date__gt */ + week_start_date__gt?: string; + /** week_start_date__lt */ + week_start_date__lt?: string; + /** week_start_date__gte */ + week_start_date__gte?: string; + /** week_start_date__lte */ + week_start_date__lte?: string; + /** A search term. */ + search?: string; + /** Which field to use when ordering the results. */ + ordering?: string; + /** Number of results to return per page. */ + limit?: number; + /** The initial index from which to return the results. */ + offset?: number; + }, + params: RequestParams = {}, + ) => + this.request< + { + count: number; + /** @format uri */ + next?: string | null; + /** @format uri */ + previous?: string | null; + results: TrainingWeekOut[]; + }, + any + >({ + path: `/training_weeks/`, + method: 'GET', + query: query, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_weeks + * @name TrainingWeeksCreate + * @request POST:/training_weeks/ + * @secure + */ + trainingWeeksCreate: (data: TrainingWeekOut, params: RequestParams = {}) => + this.request({ + path: `/training_weeks/`, + method: 'POST', + body: data, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_weeks + * @name TrainingWeeksPopulate + * @request POST:/training_weeks/populate/ + * @secure + */ + trainingWeeksPopulate: (data: TrainingWeekPopulateIn, params: RequestParams = {}) => + this.request({ + path: `/training_weeks/populate/`, + method: 'POST', + body: data, + secure: true, + type: ContentType.Json, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_weeks + * @name TrainingWeeksRead + * @request GET:/training_weeks/{id}/ + * @secure + */ + trainingWeeksRead: (id: number, params: RequestParams = {}) => + this.request({ + path: `/training_weeks/${id}/`, + method: 'GET', + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_weeks + * @name TrainingWeeksUpdate + * @request PUT:/training_weeks/{id}/ + * @secure + */ + trainingWeeksUpdate: (id: number, data: TrainingWeekOut, params: RequestParams = {}) => + this.request({ + path: `/training_weeks/${id}/`, + method: 'PUT', + body: data, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_weeks + * @name TrainingWeeksPartialUpdate + * @request PATCH:/training_weeks/{id}/ + * @secure + */ + trainingWeeksPartialUpdate: (id: number, data: TrainingWeekOut, params: RequestParams = {}) => + this.request({ + path: `/training_weeks/${id}/`, + method: 'PATCH', + body: data, + secure: true, + format: 'json', + ...params, + }), + + /** + * No description + * + * @tags training_weeks + * @name TrainingWeeksDelete + * @request DELETE:/training_weeks/{id}/ + * @secure + */ + trainingWeeksDelete: (id: number, params: RequestParams = {}) => + this.request({ + path: `/training_weeks/${id}/`, + method: 'DELETE', + secure: true, + ...params, + }), + }; users = { /** * No description diff --git a/src/ui/src/api/api-fetch.ts b/src/ui/src/api/api-fetch.ts index f7ed150..5312a44 100644 --- a/src/ui/src/api/api-fetch.ts +++ b/src/ui/src/api/api-fetch.ts @@ -75,3 +75,5 @@ export const createUseUrl = < export const useActivitiesList = createUseUrl(api.activities.activitiesList); export const useSeasonsList = createUseUrl(api.seasons.seasonsList); +export const useTrainingEntriesList = createUseUrl(api.trainingEntries.trainingEntriesList); +export const useTrainingWeeksList = createUseUrl(api.trainingWeeks.trainingWeeksList); diff --git a/src/ui/src/components/Calendar.tsx b/src/ui/src/components/Calendar.tsx index 69f894f..1441d26 100644 --- a/src/ui/src/components/Calendar.tsx +++ b/src/ui/src/components/Calendar.tsx @@ -1,4 +1,4 @@ -import { Box, Card, Center, Grid, Group, LoadingOverlay, Stack, Text } from '@mantine/core'; +import { Box, Card, Center, Grid, Group, HoverCard, LoadingOverlay, Stack, Text } from '@mantine/core'; import { IconBike, IconDeviceUnknown, IconRun, IconWalk } from '@tabler/icons-react'; import { DateTime, Interval } from 'luxon'; @@ -65,23 +65,23 @@ export const ActivityCard = ({ activity }: { activity: ActivityOut }) => { ); }; -export const TrainingCard = ({ entry }: { entry: TrainingEntryOut }) => { - return ( - - - - - - {entry.workout_type}  - - - - - - - - ); -}; +// export const TrainingCard = ({ entry }: { entry: TrainingEntryOut }) => { +// return ( +// +// +// +// +// +// {entry.workout_type}  +// +// +// +// +// +// +// +// ); +// }; export const WeekSummaryCard = ({ dates, calendar }: { dates: string[]; calendar: Record }) => { const weeklyActivityHours: Record = {}; @@ -125,6 +125,20 @@ export const WeekSummaryCard = ({ dates, calendar }: { dates: string[]; calendar ); }; +export const TrainingEntryCard = ({ entry }: { entry: TrainingEntryOut }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const details = entry.workout_types[entry.workout_type] || []; + return ( + + + {details[0]} + + {details[1]} + + ); +}; + export const Calendar = ({ activities, trainingEntries, @@ -186,34 +200,47 @@ export const Calendar = ({ - {weekChunks.map(week => { + {weekChunks.map((week, wi) => { return [ - ...week.map(date => ( - - - - - {(calendar[date].trainingEntries || []).map(entry => ( - - {entry.workout_type} | {entry.scheduled_length} - + ...week.map((date, di) => { + const ld = DateTime.fromISO(date); + return ( + + + + + {(calendar[date].trainingEntries || []).map(entry => ( + + + + {entry.workout_type} | {entry.scheduled_length} + + + + + + + ))} + + {(wi === 0 && di === 0) || ld.day === 1 ? ( + + {ld.monthShort} + + ) : null} +   + {date.split('-')[2]} + + + + + {(calendar[date].activities || []).map(act => ( + ))} - - {date.split('-')[2]} - - - - - {/* {(calendar[date].trainingEntries || []).map(entry => ( */} - {/* */} - {/* ))} */} - {(calendar[date].activities || []).map(act => ( - - ))} - - - - )), + + + + ); + }), , diff --git a/src/ui/src/components/FixedModal.tsx b/src/ui/src/components/FixedModal.tsx new file mode 100644 index 0000000..8bda724 --- /dev/null +++ b/src/ui/src/components/FixedModal.tsx @@ -0,0 +1,18 @@ +import { Modal, ModalProps } from '@mantine/core'; + +export const FixedModal = (props: ModalProps) => { + return ( + <> + + {props.withOverlay ? : null} + + + {props.title} + {props.withCloseButton ? : null} + + {props.children} + + + + ); +}; diff --git a/src/ui/src/components/Header.tsx b/src/ui/src/components/Header.tsx index 4da9806..2327a26 100644 --- a/src/ui/src/components/Header.tsx +++ b/src/ui/src/components/Header.tsx @@ -1,6 +1,7 @@ -import { Button, Modal } from '@mantine/core'; +import { Button } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { Login } from './Login'; +import { FixedModal } from './FixedModal'; export const Header = () => { const [opened, { open, close }] = useDisclosure(false); @@ -8,9 +9,9 @@ export const Header = () => { return ( <> - + - + ); }; diff --git a/src/ui/src/utils/test.ts b/src/ui/src/utils/test.ts new file mode 100644 index 0000000..889a7b4 --- /dev/null +++ b/src/ui/src/utils/test.ts @@ -0,0 +1,11 @@ +type Order = { id: number; amount: number; price: number }; + +const myFunction = (order: Order): Promise => { + const orderEntries = Object.entries(order); + const orderWithTotal = [...orderEntries, ['total', order.amount * order.price]]; + const newOrder = Object.fromEntries(orderWithTotal); + return newOrder; +}; + +const x = await myFunction({ id: 1, amount: 1, price: 1 }); +console.log(x); diff --git a/src/ui/src/views/Home.tsx b/src/ui/src/views/Home.tsx index b5073a1..7ffbe63 100644 --- a/src/ui/src/views/Home.tsx +++ b/src/ui/src/views/Home.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { LoadingOverlay } from '@mantine/core'; import { DateTime } from 'luxon'; -import { useActivitiesList } from '../api/api-fetch'; +import { useActivitiesList, useTrainingEntriesList, useTrainingWeeksList } from '../api/api-fetch'; import { Calendar } from '../components/Calendar'; import { calendarDateOffset } from '../utils/dates'; @@ -12,11 +12,20 @@ export const Home = () => { ...(firstDate ? { start_datetime_local__gte: firstDate.toISO() || '' } : null), ...(lastDate ? { start_datetime_local__lte: lastDate.toISO() || '' } : null), }); + const { data: entriesData, isLoading: isLoading2 } = useTrainingEntriesList({ + ...(firstDate ? { week__week_start_date__gte: firstDate.toISODate() || '' } : null), + ...(lastDate ? { week__week_start_date__lte: lastDate.toISODate() || '' } : null), + }); return (
- - + +
); }; diff --git a/src/ui/src/views/TrainingPlansManage.tsx b/src/ui/src/views/TrainingPlansManage.tsx index 2534531..b77a296 100644 --- a/src/ui/src/views/TrainingPlansManage.tsx +++ b/src/ui/src/views/TrainingPlansManage.tsx @@ -1,5 +1,5 @@ import { useNavigate, useParams } from 'react-router'; -import { Modal, NativeSelect } from '@mantine/core'; +import { Button, NativeSelect, Stack } from '@mantine/core'; import { useEffect, useState } from 'react'; import { DateInput } from '@mantine/dates'; import { DateTime } from 'luxon'; @@ -7,6 +7,7 @@ import { TrainingEntryOut } from '../api/DTOs'; import { Calendar } from '../components/Calendar'; import { calendarDateOffset } from '../utils/dates'; import { api } from '../api/api-fetch'; +import { FixedModal } from '../components/FixedModal'; const fixDate = (date: Date | null): Date | null => { if (date === null) { @@ -17,28 +18,31 @@ const fixDate = (date: Date | null): Date | null => { const fetchPreview = async (annual_hours: number, season_start_date: Date | null, season_end_date: Date | null) => { return api.seasons.seasonsPreviewTrainingBibleV1({ - annual_hours, - season_start_date: season_start_date ? DateTime.fromJSDate(season_start_date).toISODate() || '' : '', - season_end_date: season_end_date ? DateTime.fromJSDate(season_end_date).toISODate() || '' : '', + params: { annual_hours }, + season_start_date: season_start_date ? DateTime.fromJSDate(season_start_date).toISODate() || null : null, + season_end_date: season_end_date ? DateTime.fromJSDate(season_end_date).toISODate() || null : null, }); }; export const TrainingPlanForm = () => { const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); - const [yearlyHours, setYearlyHours] = useState(200); - const [yearlyHourChoices, setYearlyHourChoices] = useState([]); + const [annualHours, setAnnualHours] = useState(200); + const [annualHourChoices, setAnnualHourChoices] = useState([]); const [entries, setEntries] = useState([]); + const [saving, setSaving] = useState(false); useEffect(() => { const fetchData = async () => { - const { data } = await fetchPreview(yearlyHours, startDate, endDate); - setYearlyHourChoices(data.hour_selection); + const { data } = await fetchPreview(annualHours, startDate, endDate); + setAnnualHourChoices(data.hour_selection); setEntries(data.entries); }; - fetchData(); - }, [startDate, endDate, yearlyHours]); + if (startDate) { + fetchData().then(_ => {}); + } + }, [startDate, endDate, annualHours]); const firstDate = entries.map(e => DateTime.fromISO(e.entry_date)).sort()[0]; const lastDate = entries @@ -49,27 +53,46 @@ export const TrainingPlanForm = () => { console.log({ firstDate, lastDate }); return ( -
+ setStartDate(fixDate(date))} label="Start date" placeholder="Start date" /> String(h))} - onChange={event => setYearlyHours(Number(event.currentTarget.value))} + data={annualHourChoices.map(h => String(h))} + onChange={event => setAnnualHours(Number(event.currentTarget.value))} /> - {firstDate && lastDate && } -
+ +
+ {entries && entries.length > 0 && } + ); }; export const TrainingPlansManage = () => { const navigate = useNavigate(); const { command } = useParams(); + return ( -
- navigate('/training')} title="Create new Training Plan Season"> - - -
+ navigate('/training')} title="Create new Training Plan Season"> + + ); };