mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-04 20:55:19 +02:00
Add StravaActivity and Activity types to types.ts
- Introduced StravaActivity type to represent detailed activity data from Strava. - Added Activity type to encapsulate user activities, including optional trail and GPX file information. - Updated Location type to include an array of activities associated with each visit.
This commit is contained in:
parent
c7ff8f4bc7
commit
5046bd49f7
13 changed files with 1626 additions and 255 deletions
|
@ -1,7 +1,7 @@
|
||||||
import os
|
import os
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import mark_safe
|
from django.utils.html import mark_safe
|
||||||
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
|
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity
|
||||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||||
from allauth.account.decorators import secure_admin_login
|
from allauth.account.decorators import secure_admin_login
|
||||||
|
|
||||||
|
@ -165,6 +165,7 @@ admin.site.register(ContentAttachment)
|
||||||
admin.site.register(Lodging)
|
admin.site.register(Lodging)
|
||||||
admin.site.register(CollectionInvite, CollectionInviteAdmin)
|
admin.site.register(CollectionInvite, CollectionInviteAdmin)
|
||||||
admin.site.register(Trail)
|
admin.site.register(Trail)
|
||||||
|
admin.site.register(Activity)
|
||||||
|
|
||||||
admin.site.site_header = 'AdventureLog Admin'
|
admin.site.site_header = 'AdventureLog Admin'
|
||||||
admin.site.site_title = 'AdventureLog Admin Site'
|
admin.site.site_title = 'AdventureLog Admin Site'
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -619,3 +619,58 @@ class Trail(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
|
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
|
||||||
|
|
||||||
|
class Activity(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
|
||||||
|
visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name='activities')
|
||||||
|
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, related_name='activities', blank=True, null=True)
|
||||||
|
|
||||||
|
# GPX File
|
||||||
|
gpx_file = models.FileField(upload_to=PathAndRename('activities/'), validators=[validate_file_extension], blank=True, null=True)
|
||||||
|
|
||||||
|
# Descriptive
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
type = models.CharField(max_length=100, default='general') # E.g., Run, Hike, Bike
|
||||||
|
sport_type = models.CharField(max_length=100, blank=True, null=True) # Optional detailed type
|
||||||
|
|
||||||
|
# Time & Distance
|
||||||
|
distance = models.FloatField(blank=True, null=True) # in meters
|
||||||
|
moving_time = models.DurationField(blank=True, null=True)
|
||||||
|
elapsed_time = models.DurationField(blank=True, null=True)
|
||||||
|
rest_time = models.DurationField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Elevation
|
||||||
|
elevation_gain = models.FloatField(blank=True, null=True) # in meters
|
||||||
|
elevation_loss = models.FloatField(blank=True, null=True) # estimated
|
||||||
|
elev_high = models.FloatField(blank=True, null=True)
|
||||||
|
elev_low = models.FloatField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
start_date = models.DateTimeField(blank=True, null=True)
|
||||||
|
start_date_local = models.DateTimeField(blank=True, null=True)
|
||||||
|
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], blank=True, null=True)
|
||||||
|
|
||||||
|
# Speed
|
||||||
|
average_speed = models.FloatField(blank=True, null=True) # in m/s
|
||||||
|
max_speed = models.FloatField(blank=True, null=True) # in m/s
|
||||||
|
|
||||||
|
# Optional metrics
|
||||||
|
average_cadence = models.FloatField(blank=True, null=True)
|
||||||
|
calories = models.FloatField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Coordinates
|
||||||
|
start_lat = models.FloatField(blank=True, null=True)
|
||||||
|
start_lng = models.FloatField(blank=True, null=True)
|
||||||
|
end_lat = models.FloatField(blank=True, null=True)
|
||||||
|
end_lng = models.FloatField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Optional links
|
||||||
|
external_service_id = models.CharField(max_length=100, blank=True, null=True) # E.g., Strava ID
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.type})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Activity"
|
||||||
|
verbose_name_plural = "Activities"
|
|
@ -112,6 +112,11 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||||
if type(obj).__name__ == 'Trail':
|
if type(obj).__name__ == 'Trail':
|
||||||
obj = obj.location
|
obj = obj.location
|
||||||
|
|
||||||
|
if type(obj).__name__ == 'Activity':
|
||||||
|
# If the object is an Activity, get its location
|
||||||
|
if hasattr(obj, 'visit') and hasattr(obj.visit, 'location'):
|
||||||
|
obj = obj.visit.location
|
||||||
|
|
||||||
# Anonymous users only get read access to public objects
|
# Anonymous users only get read access to public objects
|
||||||
if not user or not user.is_authenticated:
|
if not user or not user.is_authenticated:
|
||||||
return is_safe_method and getattr(obj, 'is_public', False)
|
return is_safe_method and getattr(obj, 'is_public', False)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import os
|
import os
|
||||||
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
|
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from main.utils import CustomModelSerializer
|
from main.utils import CustomModelSerializer
|
||||||
from users.serializers import CustomUserDetailsSerializer
|
from users.serializers import CustomUserDetailsSerializer
|
||||||
|
@ -106,11 +106,34 @@ class TrailSerializer(CustomModelSerializer):
|
||||||
return 'Outdooractive'
|
return 'Outdooractive'
|
||||||
return 'External Link'
|
return 'External Link'
|
||||||
|
|
||||||
|
class ActivitySerializer(CustomModelSerializer):
|
||||||
|
trail = TrailSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Activity
|
||||||
|
fields = [
|
||||||
|
'id', 'user', 'visit', 'trail', 'gpx_file', 'name', 'type', 'sport_type',
|
||||||
|
'distance', 'moving_time', 'elapsed_time', 'rest_time', 'elevation_gain',
|
||||||
|
'elevation_loss', 'elev_high', 'elev_low', 'start_date', 'start_date_local',
|
||||||
|
'timezone', 'average_speed', 'max_speed', 'average_cadence', 'calories',
|
||||||
|
'start_lat', 'start_lng', 'end_lat', 'end_lng', 'external_service_id'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'user']
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
representation = super().to_representation(instance)
|
||||||
|
if instance.gpx_file:
|
||||||
|
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/').replace("'", "")
|
||||||
|
representation['gpx_file'] = f"{public_url}/media/{instance.gpx_file.name}"
|
||||||
|
return representation
|
||||||
|
|
||||||
|
|
||||||
class VisitSerializer(serializers.ModelSerializer):
|
class VisitSerializer(serializers.ModelSerializer):
|
||||||
|
activities = ActivitySerializer(many=True, read_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Visit
|
model = Visit
|
||||||
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes']
|
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities']
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
|
||||||
class LocationSerializer(CustomModelSerializer):
|
class LocationSerializer(CustomModelSerializer):
|
||||||
|
|
|
@ -21,6 +21,7 @@ router.register(r'lodging', LodgingViewSet, basename='lodging')
|
||||||
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
|
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
|
||||||
router.register(r'backup', BackupViewSet, basename='backup')
|
router.register(r'backup', BackupViewSet, basename='backup')
|
||||||
router.register(r'trails', TrailViewSet, basename='trails')
|
router.register(r'trails', TrailViewSet, basename='trails')
|
||||||
|
router.register(r'activities', ActivityViewSet, basename='activities')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Include the router under the 'api/' prefix
|
# Include the router under the 'api/' prefix
|
||||||
|
|
|
@ -16,3 +16,4 @@ from .lodging_view import *
|
||||||
from .recommendations_view import *
|
from .recommendations_view import *
|
||||||
from .import_export_view import *
|
from .import_export_view import *
|
||||||
from .trail_view import *
|
from .trail_view import *
|
||||||
|
from .activity_view import *
|
40
backend/server/adventures/views/activity_view.py
Normal file
40
backend/server/adventures/views/activity_view.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from django.db.models import Q
|
||||||
|
from adventures.models import Location, Activity
|
||||||
|
from adventures.serializers import ActivitySerializer
|
||||||
|
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||||
|
|
||||||
|
class ActivityViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = ActivitySerializer
|
||||||
|
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Returns activities based on location permissions.
|
||||||
|
Users can only see activities in locations they have access to for editing/updating/deleting.
|
||||||
|
This means they are either:
|
||||||
|
- The owner of the location
|
||||||
|
- The location is in a collection that is shared with the user
|
||||||
|
- The location is in a collection that the user owns
|
||||||
|
"""
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return Activity.objects.none()
|
||||||
|
|
||||||
|
# Build the filter for accessible locations
|
||||||
|
location_filter = Q(visit__location__user=user) # User owns the location
|
||||||
|
|
||||||
|
# Location is in collections (many-to-many) that are shared with user
|
||||||
|
location_filter |= Q(visit__location__collections__shared_with=user)
|
||||||
|
|
||||||
|
# Location is in collections (many-to-many) that user owns
|
||||||
|
location_filter |= Q(visit__location__collections__user=user)
|
||||||
|
|
||||||
|
return Activity.objects.filter(location_filter).distinct()
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""
|
||||||
|
Set the user when creating an activity.
|
||||||
|
"""
|
||||||
|
serializer.save(user=self.request.user)
|
|
@ -5,15 +5,53 @@ from rest_framework.decorators import action
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from integrations.models import StravaToken
|
from integrations.models import StravaToken
|
||||||
|
from adventures.utils.timezones import TIMEZONES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class StravaIntegrationView(viewsets.ViewSet):
|
class StravaIntegrationView(viewsets.ViewSet):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def extract_timezone_from_strava(self, strava_timezone):
|
||||||
|
"""
|
||||||
|
Extract IANA timezone from Strava's GMT offset format
|
||||||
|
Input: "(GMT-05:00) America/New_York" or "(GMT+01:00) Europe/Zurich"
|
||||||
|
Output: "America/New_York" if it exists in TIMEZONES, otherwise None
|
||||||
|
"""
|
||||||
|
if not strava_timezone:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use regex to extract the IANA timezone identifier
|
||||||
|
# Pattern matches: (GMT±XX:XX) Timezone/Name
|
||||||
|
match = re.search(r'\(GMT[+-]\d{2}:\d{2}\)\s*(.+)', strava_timezone)
|
||||||
|
if match:
|
||||||
|
timezone_name = match.group(1).strip()
|
||||||
|
# Check if this timezone exists in our TIMEZONES list
|
||||||
|
if timezone_name in TIMEZONES:
|
||||||
|
return timezone_name
|
||||||
|
|
||||||
|
# If no match or timezone not in our list, try to find a close match
|
||||||
|
# This handles cases where Strava might use slightly different names
|
||||||
|
if match:
|
||||||
|
timezone_name = match.group(1).strip()
|
||||||
|
# Try some common variations
|
||||||
|
variations = [
|
||||||
|
timezone_name,
|
||||||
|
timezone_name.replace('_', '/'),
|
||||||
|
timezone_name.replace('/', '_'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for variation in variations:
|
||||||
|
if variation in TIMEZONES:
|
||||||
|
return variation
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='authorize')
|
@action(detail=False, methods=['get'], url_path='authorize')
|
||||||
def authorize(self, request):
|
def authorize(self, request):
|
||||||
"""
|
"""
|
||||||
|
@ -88,13 +126,12 @@ class StravaIntegrationView(viewsets.ViewSet):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# redirect to frontend url / setttigns
|
# redirect to frontend url / settings
|
||||||
frontend_url = settings.FRONTEND_URL
|
frontend_url = settings.FRONTEND_URL
|
||||||
if not frontend_url.endswith('/'):
|
if not frontend_url.endswith('/'):
|
||||||
frontend_url += '/'
|
frontend_url += '/'
|
||||||
return redirect(f"{frontend_url}settings?tab=integrations")
|
return redirect(f"{frontend_url}settings?tab=integrations")
|
||||||
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.error("Error during Strava OAuth token exchange: %s", str(e))
|
logger.error("Error during Strava OAuth token exchange: %s", str(e))
|
||||||
return Response(
|
return Response(
|
||||||
|
@ -178,34 +215,150 @@ class StravaIntegrationView(viewsets.ViewSet):
|
||||||
|
|
||||||
return strava_token, None
|
return strava_token, None
|
||||||
|
|
||||||
def extract_essential_activity_info(self,activity):
|
def extract_essential_activity_info(self, activity):
|
||||||
# Pick only essential fields from a single activity dict
|
"""
|
||||||
|
Extract essential fields from a single activity dict with enhanced metrics
|
||||||
|
"""
|
||||||
|
# Calculate additional elevation metrics
|
||||||
|
elev_high = activity.get("elev_high")
|
||||||
|
elev_low = activity.get("elev_low")
|
||||||
|
total_elevation_gain = activity.get("total_elevation_gain", 0)
|
||||||
|
|
||||||
|
# Calculate total elevation loss (approximate)
|
||||||
|
total_elevation_range = None
|
||||||
|
estimated_elevation_loss = None
|
||||||
|
if elev_high is not None and elev_low is not None:
|
||||||
|
total_elevation_range = elev_high - elev_low
|
||||||
|
estimated_elevation_loss = max(0, total_elevation_range - total_elevation_gain)
|
||||||
|
|
||||||
|
# Calculate pace metrics
|
||||||
|
moving_time = activity.get("moving_time")
|
||||||
|
distance = activity.get("distance")
|
||||||
|
pace_per_km = None
|
||||||
|
pace_per_mile = None
|
||||||
|
if moving_time and distance and distance > 0:
|
||||||
|
pace_per_km = moving_time / (distance / 1000)
|
||||||
|
pace_per_mile = moving_time / (distance / 1609.34)
|
||||||
|
|
||||||
|
# Calculate efficiency metrics
|
||||||
|
grade_adjusted_speed = None
|
||||||
|
if activity.get("splits_metric") and len(activity.get("splits_metric", [])) > 0:
|
||||||
|
splits = activity.get("splits_metric", [])
|
||||||
|
grade_speeds = [split.get("average_grade_adjusted_speed") for split in splits if split.get("average_grade_adjusted_speed")]
|
||||||
|
if grade_speeds:
|
||||||
|
grade_adjusted_speed = sum(grade_speeds) / len(grade_speeds)
|
||||||
|
|
||||||
|
# Calculate time metrics
|
||||||
|
elapsed_time = activity.get("elapsed_time")
|
||||||
|
moving_time = activity.get("moving_time")
|
||||||
|
rest_time = None
|
||||||
|
if elapsed_time and moving_time:
|
||||||
|
rest_time = elapsed_time - moving_time
|
||||||
|
|
||||||
|
# Extract and normalize timezone
|
||||||
|
strava_timezone = activity.get("timezone")
|
||||||
|
normalized_timezone = self.extract_timezone_from_strava(strava_timezone)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
# Basic activity info
|
||||||
"id": activity.get("id"),
|
"id": activity.get("id"),
|
||||||
"name": activity.get("name"),
|
"name": activity.get("name"),
|
||||||
"type": activity.get("type"),
|
"type": activity.get("type"),
|
||||||
"distance": activity.get("distance"),
|
"sport_type": activity.get("sport_type"),
|
||||||
"moving_time": activity.get("moving_time"),
|
|
||||||
"elapsed_time": activity.get("elapsed_time"),
|
# Distance and time
|
||||||
"total_elevation_gain": activity.get("total_elevation_gain"),
|
"distance": activity.get("distance"), # meters
|
||||||
|
"distance_km": round(activity.get("distance", 0) / 1000, 2) if activity.get("distance") else None,
|
||||||
|
"distance_miles": round(activity.get("distance", 0) / 1609.34, 2) if activity.get("distance") else None,
|
||||||
|
"moving_time": activity.get("moving_time"), # seconds
|
||||||
|
"elapsed_time": activity.get("elapsed_time"), # seconds
|
||||||
|
"rest_time": rest_time, # seconds of non-moving time
|
||||||
|
|
||||||
|
# Enhanced elevation metrics
|
||||||
|
"total_elevation_gain": activity.get("total_elevation_gain"), # meters
|
||||||
|
"estimated_elevation_loss": estimated_elevation_loss, # meters (estimated)
|
||||||
|
"elev_high": activity.get("elev_high"), # highest point in meters
|
||||||
|
"elev_low": activity.get("elev_low"), # lowest point in meters
|
||||||
|
"total_elevation_range": total_elevation_range, # difference between high and low
|
||||||
|
|
||||||
|
# Date and location
|
||||||
"start_date": activity.get("start_date"),
|
"start_date": activity.get("start_date"),
|
||||||
"start_date_local": activity.get("start_date_local"),
|
"start_date_local": activity.get("start_date_local"),
|
||||||
"timezone": activity.get("timezone"),
|
"timezone": normalized_timezone, # Normalized IANA timezone
|
||||||
"average_speed": activity.get("average_speed"),
|
"timezone_raw": strava_timezone, # Original Strava format for reference
|
||||||
"max_speed": activity.get("max_speed"),
|
|
||||||
|
# Speed and pace metrics
|
||||||
|
"average_speed": activity.get("average_speed"), # m/s
|
||||||
|
"average_speed_kmh": round(activity.get("average_speed", 0) * 3.6, 2) if activity.get("average_speed") else None,
|
||||||
|
"average_speed_mph": round(activity.get("average_speed", 0) * 2.237, 2) if activity.get("average_speed") else None,
|
||||||
|
"max_speed": activity.get("max_speed"), # m/s
|
||||||
|
"max_speed_kmh": round(activity.get("max_speed", 0) * 3.6, 2) if activity.get("max_speed") else None,
|
||||||
|
"max_speed_mph": round(activity.get("max_speed", 0) * 2.237, 2) if activity.get("max_speed") else None,
|
||||||
|
"pace_per_km_seconds": pace_per_km, # seconds per km
|
||||||
|
"pace_per_mile_seconds": pace_per_mile, # seconds per mile
|
||||||
|
"grade_adjusted_average_speed": grade_adjusted_speed, # m/s accounting for elevation
|
||||||
|
|
||||||
|
# Performance metrics
|
||||||
"average_cadence": activity.get("average_cadence"),
|
"average_cadence": activity.get("average_cadence"),
|
||||||
"average_watts": activity.get("average_watts"),
|
"average_watts": activity.get("average_watts"),
|
||||||
"max_watts": activity.get("max_watts"),
|
"max_watts": activity.get("max_watts"),
|
||||||
"kilojoules": activity.get("kilojoules"),
|
"kilojoules": activity.get("kilojoules"),
|
||||||
"calories": activity.get("calories"),
|
"calories": activity.get("calories"),
|
||||||
|
|
||||||
|
# Achievement metrics
|
||||||
|
"achievement_count": activity.get("achievement_count"),
|
||||||
|
"kudos_count": activity.get("kudos_count"),
|
||||||
|
"comment_count": activity.get("comment_count"),
|
||||||
|
"pr_count": activity.get("pr_count"), # personal records achieved
|
||||||
|
|
||||||
|
# Equipment and technical
|
||||||
"gear_id": activity.get("gear_id"),
|
"gear_id": activity.get("gear_id"),
|
||||||
"device_name": activity.get("device_name"),
|
"device_name": activity.get("device_name"),
|
||||||
|
"trainer": activity.get("trainer"), # indoor trainer activity
|
||||||
|
"manual": activity.get("manual"), # manually entered
|
||||||
|
|
||||||
|
# GPS coordinates
|
||||||
"start_latlng": activity.get("start_latlng"),
|
"start_latlng": activity.get("start_latlng"),
|
||||||
"end_latlng": activity.get("end_latlng"),
|
"end_latlng": activity.get("end_latlng"),
|
||||||
|
|
||||||
|
# Export links
|
||||||
'export_original': f'https://www.strava.com/activities/{activity.get("id")}/export_original',
|
'export_original': f'https://www.strava.com/activities/{activity.get("id")}/export_original',
|
||||||
'export_gpx': f'https://www.strava.com/activities/{activity.get("id")}/export_gpx',
|
'export_gpx': f'https://www.strava.com/activities/{activity.get("id")}/export_gpx',
|
||||||
|
|
||||||
|
# Additional useful fields
|
||||||
|
"visibility": activity.get("visibility"),
|
||||||
|
"photo_count": activity.get("photo_count"),
|
||||||
|
"has_heartrate": activity.get("has_heartrate"),
|
||||||
|
"flagged": activity.get("flagged"),
|
||||||
|
"commute": activity.get("commute"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_pace_readable(pace_seconds):
|
||||||
|
"""
|
||||||
|
Helper function to convert pace in seconds to readable format (MM:SS)
|
||||||
|
"""
|
||||||
|
if pace_seconds is None:
|
||||||
|
return None
|
||||||
|
minutes = int(pace_seconds // 60)
|
||||||
|
seconds = int(pace_seconds % 60)
|
||||||
|
return f"{minutes}:{seconds:02d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_time_readable(time_seconds):
|
||||||
|
"""
|
||||||
|
Helper function to convert time in seconds to readable format (HH:MM:SS)
|
||||||
|
"""
|
||||||
|
if time_seconds is None:
|
||||||
|
return None
|
||||||
|
hours = int(time_seconds // 3600)
|
||||||
|
minutes = int((time_seconds % 3600) // 60)
|
||||||
|
seconds = int(time_seconds % 60)
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
||||||
|
else:
|
||||||
|
return f"{minutes}:{seconds:02d}"
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='activities')
|
@action(detail=False, methods=['get'], url_path='activities')
|
||||||
def activities(self, request):
|
def activities(self, request):
|
||||||
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
|
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
|
||||||
|
@ -215,14 +368,17 @@ class StravaIntegrationView(viewsets.ViewSet):
|
||||||
# Get date parameters from query string
|
# Get date parameters from query string
|
||||||
start_date = request.query_params.get('start_date')
|
start_date = request.query_params.get('start_date')
|
||||||
end_date = request.query_params.get('end_date')
|
end_date = request.query_params.get('end_date')
|
||||||
|
per_page = request.query_params.get('per_page', 30) # Default to 30 activities
|
||||||
|
page = request.query_params.get('page', 1)
|
||||||
|
|
||||||
# Build query parameters for Strava API
|
# Build query parameters for Strava API
|
||||||
params = {}
|
params = {
|
||||||
|
'per_page': min(int(per_page), 200), # Strava max is 200
|
||||||
|
'page': int(page)
|
||||||
|
}
|
||||||
|
|
||||||
if start_date:
|
if start_date:
|
||||||
try:
|
try:
|
||||||
# Parse the date string and convert to Unix timestamp
|
|
||||||
from datetime import datetime
|
|
||||||
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||||
params['after'] = int(start_dt.timestamp())
|
params['after'] = int(start_dt.timestamp())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -234,8 +390,6 @@ class StravaIntegrationView(viewsets.ViewSet):
|
||||||
|
|
||||||
if end_date:
|
if end_date:
|
||||||
try:
|
try:
|
||||||
# Parse the date string and convert to Unix timestamp
|
|
||||||
from datetime import datetime
|
|
||||||
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||||
params['before'] = int(end_dt.timestamp())
|
params['before'] = int(end_dt.timestamp())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -260,7 +414,12 @@ class StravaIntegrationView(viewsets.ViewSet):
|
||||||
activities = response.json()
|
activities = response.json()
|
||||||
essential_activities = [self.extract_essential_activity_info(act) for act in activities]
|
essential_activities = [self.extract_essential_activity_info(act) for act in activities]
|
||||||
|
|
||||||
return Response(essential_activities, status=status.HTTP_200_OK)
|
return Response({
|
||||||
|
'activities': essential_activities,
|
||||||
|
'count': len(essential_activities),
|
||||||
|
'page': int(page),
|
||||||
|
'per_page': int(per_page)
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.error(f"Error fetching Strava activities: {str(e)}")
|
logger.error(f"Error fetching Strava activities: {str(e)}")
|
||||||
|
|
|
@ -285,6 +285,7 @@
|
||||||
{#if steps[3].selected}
|
{#if steps[3].selected}
|
||||||
<LocationVisits
|
<LocationVisits
|
||||||
bind:visits={location.visits}
|
bind:visits={location.visits}
|
||||||
|
bind:trails={location.trails}
|
||||||
objectId={location.id}
|
objectId={location.id}
|
||||||
type="location"
|
type="location"
|
||||||
on:back={() => {
|
on:back={() => {
|
||||||
|
|
205
frontend/src/lib/components/StravaActivityCard.svelte
Normal file
205
frontend/src/lib/components/StravaActivityCard.svelte
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { StravaActivity } from '$lib/types';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let activity: StravaActivity;
|
||||||
|
|
||||||
|
interface SportConfig {
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sportTypeConfig: Record<string, SportConfig> = {
|
||||||
|
StandUpPaddling: { color: 'info', icon: '🏄', name: 'Stand Up Paddling' },
|
||||||
|
Run: { color: 'success', icon: '🏃', name: 'Running' },
|
||||||
|
Ride: { color: 'warning', icon: '🚴', name: 'Cycling' },
|
||||||
|
Swim: { color: 'primary', icon: '🏊', name: 'Swimming' },
|
||||||
|
Hike: { color: 'accent', icon: '🥾', name: 'Hiking' },
|
||||||
|
Walk: { color: 'neutral', icon: '🚶', name: 'Walking' },
|
||||||
|
default: { color: 'secondary', icon: '⚡', name: 'Activity' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTypeConfig(type: string): SportConfig {
|
||||||
|
return sportTypeConfig[type] || sportTypeConfig.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${secs}s`;
|
||||||
|
}
|
||||||
|
return `${minutes}m ${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPace(seconds: number): string {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImportActivity() {
|
||||||
|
dispatch('import', activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: typeConfig = getTypeConfig(activity.sport_type);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-50 border border-base-200 hover:shadow-md transition-shadow">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<!-- Activity Header -->
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="text-2xl" aria-label="Sport icon">{typeConfig.icon}</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-lg">{activity.name}</h3>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<span class="badge badge-{typeConfig.color} badge-sm">{typeConfig.name}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDate(activity.start_date)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<div
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
class="btn btn-ghost btn-sm btn-circle"
|
||||||
|
aria-label="Activity options"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zM12 13a1 1 0 110-2 1 1 0 010 2zM12 20a1 1 0 110-2 1 1 0 010 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||||
|
<ul
|
||||||
|
tabindex="0"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href={activity.export_gpx} target="_blank" rel="noopener noreferrer"> Export GPX </a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={activity.export_original} target="_blank" rel="noopener noreferrer">
|
||||||
|
Export Original
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.strava.com/activities/{activity.id}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View on Strava
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- import button -->
|
||||||
|
<li>
|
||||||
|
<button type="button" on:click={handleImportActivity} class="text-primary"
|
||||||
|
>Import Activity</button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Stats -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="stat bg-base-100 rounded-lg p-3">
|
||||||
|
<div class="stat-title text-xs">Distance</div>
|
||||||
|
<div class="stat-value text-lg">{activity.distance_km.toFixed(2)}</div>
|
||||||
|
<div class="stat-desc">km ({activity.distance_miles.toFixed(2)} mi)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-100 rounded-lg p-3">
|
||||||
|
<div class="stat-title text-xs">Time</div>
|
||||||
|
<div class="stat-value text-lg">{formatTime(activity.moving_time)}</div>
|
||||||
|
<div class="stat-desc">Moving time</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-100 rounded-lg p-3">
|
||||||
|
<div class="stat-title text-xs">Avg Speed</div>
|
||||||
|
<div class="stat-value text-lg">{activity.average_speed_kmh.toFixed(1)}</div>
|
||||||
|
<div class="stat-desc">km/h ({activity.average_speed_mph.toFixed(1)} mph)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-100 rounded-lg p-3">
|
||||||
|
<div class="stat-title text-xs">Elevation</div>
|
||||||
|
<div class="stat-value text-lg">{activity.total_elevation_gain.toFixed(0)}</div>
|
||||||
|
<div class="stat-desc">m gain</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Stats -->
|
||||||
|
<div class="flex flex-wrap gap-2 text-sm">
|
||||||
|
{#if activity.average_cadence}
|
||||||
|
<div class="badge badge-ghost">
|
||||||
|
<span class="font-medium">Cadence:</span> {activity.average_cadence.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if activity.calories}
|
||||||
|
<div class="badge badge-ghost">
|
||||||
|
<span class="font-medium">Calories:</span> {activity.calories}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if activity.kudos_count > 0}
|
||||||
|
<div class="badge badge-ghost">
|
||||||
|
<span class="font-medium">Kudos:</span> {activity.kudos_count}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if activity.achievement_count > 0}
|
||||||
|
<div class="badge badge-success badge-outline">
|
||||||
|
<span class="font-medium">Achievements:</span> {activity.achievement_count}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if activity.pr_count > 0}
|
||||||
|
<div class="badge badge-warning badge-outline">
|
||||||
|
<span class="font-medium">PRs:</span> {activity.pr_count}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer with pace and max speed -->
|
||||||
|
{#if activity.pace_per_km_seconds}
|
||||||
|
<div class="flex justify-between items-center mt-3 pt-3 border-t border-base-300">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">Pace:</span>
|
||||||
|
{formatPace(activity.pace_per_km_seconds)}/km
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">Max Speed:</span>
|
||||||
|
{activity.max_speed_kmh.toFixed(1)} km/h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stat {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
File diff suppressed because it is too large
Load diff
|
@ -36,6 +36,7 @@ export type Location = {
|
||||||
end_date: string;
|
end_date: string;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
activities: Activity[]; // Array of activities associated with the visit
|
||||||
}[];
|
}[];
|
||||||
collections?: string[] | null;
|
collections?: string[] | null;
|
||||||
latitude: number | null;
|
latitude: number | null;
|
||||||
|
@ -328,3 +329,87 @@ export type Trail = {
|
||||||
wanderer_id?: string | null; // Optional ID for integration with Wanderer
|
wanderer_id?: string | null; // Optional ID for integration with Wanderer
|
||||||
provider: string; // Provider of the trail data, e.g., 'wanderer', 'external'
|
provider: string; // Provider of the trail data, e.g., 'wanderer', 'external'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StravaActivity = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
sport_type: string;
|
||||||
|
distance: number;
|
||||||
|
distance_km: number;
|
||||||
|
distance_miles: number;
|
||||||
|
moving_time: number;
|
||||||
|
elapsed_time: number;
|
||||||
|
rest_time: number;
|
||||||
|
total_elevation_gain: number;
|
||||||
|
estimated_elevation_loss: number;
|
||||||
|
elev_high: number;
|
||||||
|
elev_low: number;
|
||||||
|
total_elevation_range: number;
|
||||||
|
start_date: string; // ISO 8601 format
|
||||||
|
start_date_local: string; // ISO 8601 format
|
||||||
|
timezone: string;
|
||||||
|
timezone_raw: string;
|
||||||
|
average_speed: number;
|
||||||
|
average_speed_kmh: number;
|
||||||
|
average_speed_mph: number;
|
||||||
|
max_speed: number;
|
||||||
|
max_speed_kmh: number;
|
||||||
|
max_speed_mph: number;
|
||||||
|
pace_per_km_seconds: number;
|
||||||
|
pace_per_mile_seconds: number;
|
||||||
|
grade_adjusted_average_speed: number | null;
|
||||||
|
average_cadence: number | null;
|
||||||
|
average_watts: number | null;
|
||||||
|
max_watts: number | null;
|
||||||
|
kilojoules: number | null;
|
||||||
|
calories: number | null;
|
||||||
|
achievement_count: number;
|
||||||
|
kudos_count: number;
|
||||||
|
comment_count: number;
|
||||||
|
pr_count: number;
|
||||||
|
gear_id: string | null;
|
||||||
|
device_name: string | null;
|
||||||
|
trainer: boolean;
|
||||||
|
manual: boolean;
|
||||||
|
start_latlng: [number, number] | null; // [latitude, longitude]
|
||||||
|
end_latlng: [number, number] | null; // [latitude, longitude]
|
||||||
|
export_original: string; // URL
|
||||||
|
export_gpx: string; // URL
|
||||||
|
visibility: string;
|
||||||
|
photo_count: number;
|
||||||
|
has_heartrate: boolean;
|
||||||
|
flagged: boolean;
|
||||||
|
commute: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Activity = {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
visit: string;
|
||||||
|
trail: Trail | null;
|
||||||
|
gpx_file: string | null;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
sport_type: string | null;
|
||||||
|
distance: number | null;
|
||||||
|
moving_time: string | null; // ISO 8601 duration string
|
||||||
|
elapsed_time: string | null; // ISO 8601 duration string
|
||||||
|
rest_time: string | null; // ISO 8601 duration string
|
||||||
|
elevation_gain: number | null;
|
||||||
|
elevation_loss: number | null;
|
||||||
|
elev_high: number | null;
|
||||||
|
elev_low: number | null;
|
||||||
|
start_date: string | null; // ISO 8601 date string
|
||||||
|
start_date_local: string | null; // ISO 8601 date string
|
||||||
|
timezone: string | null;
|
||||||
|
average_speed: number | null;
|
||||||
|
max_speed: number | null;
|
||||||
|
average_cadence: number | null;
|
||||||
|
calories: number | null;
|
||||||
|
start_lat: number | null;
|
||||||
|
start_lng: number | null;
|
||||||
|
end_lat: number | null;
|
||||||
|
end_lng: number | null;
|
||||||
|
external_service_id: string | null;
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue