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,8 +1,8 @@
|
|||
import os
|
||||
from django.contrib import admin
|
||||
from django.utils.html import mark_safe
|
||||
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
|
||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
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 allauth.account.decorators import secure_admin_login
|
||||
|
||||
admin.autodiscover()
|
||||
|
@ -165,6 +165,7 @@ admin.site.register(ContentAttachment)
|
|||
admin.site.register(Lodging)
|
||||
admin.site.register(CollectionInvite, CollectionInviteAdmin)
|
||||
admin.site.register(Trail)
|
||||
admin.site.register(Activity)
|
||||
|
||||
admin.site.site_header = 'AdventureLog Admin'
|
||||
admin.site.site_title = 'AdventureLog Admin Site'
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -618,4 +618,59 @@ class Trail(models.Model):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
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"
|
|
@ -111,7 +111,12 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
|||
# If the object has a location field, get that location and continue checking with that object, basically from the location's perspective. I am very proud of this line of code and that's why I am writing this comment.
|
||||
if type(obj).__name__ == 'Trail':
|
||||
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
|
||||
if not user or not user.is_authenticated:
|
||||
return is_safe_method and getattr(obj, 'is_public', False)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.utils import timezone
|
||||
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 main.utils import CustomModelSerializer
|
||||
from users.serializers import CustomUserDetailsSerializer
|
||||
|
@ -106,11 +106,34 @@ class TrailSerializer(CustomModelSerializer):
|
|||
return 'Outdooractive'
|
||||
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):
|
||||
activities = ActivitySerializer(many=True, read_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Visit
|
||||
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes']
|
||||
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities']
|
||||
read_only_fields = ['id']
|
||||
|
||||
class LocationSerializer(CustomModelSerializer):
|
||||
|
|
|
@ -21,6 +21,7 @@ router.register(r'lodging', LodgingViewSet, basename='lodging')
|
|||
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
|
||||
router.register(r'backup', BackupViewSet, basename='backup')
|
||||
router.register(r'trails', TrailViewSet, basename='trails')
|
||||
router.register(r'activities', ActivityViewSet, basename='activities')
|
||||
|
||||
urlpatterns = [
|
||||
# Include the router under the 'api/' prefix
|
||||
|
|
|
@ -15,4 +15,5 @@ from .attachment_view import *
|
|||
from .lodging_view import *
|
||||
from .recommendations_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 logging
|
||||
import time
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from django.shortcuts import redirect
|
||||
from django.conf import settings
|
||||
from integrations.models import StravaToken
|
||||
from adventures.utils.timezones import TIMEZONES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class StravaIntegrationView(viewsets.ViewSet):
|
||||
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')
|
||||
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
|
||||
if not frontend_url.endswith('/'):
|
||||
frontend_url += '/'
|
||||
return redirect(f"{frontend_url}settings?tab=integrations")
|
||||
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error("Error during Strava OAuth token exchange: %s", str(e))
|
||||
return Response(
|
||||
|
@ -178,34 +215,150 @@ class StravaIntegrationView(viewsets.ViewSet):
|
|||
|
||||
return strava_token, None
|
||||
|
||||
def extract_essential_activity_info(self,activity):
|
||||
# Pick only essential fields from a single activity dict
|
||||
def extract_essential_activity_info(self, activity):
|
||||
"""
|
||||
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 {
|
||||
# Basic activity info
|
||||
"id": activity.get("id"),
|
||||
"name": activity.get("name"),
|
||||
"type": activity.get("type"),
|
||||
"distance": activity.get("distance"),
|
||||
"moving_time": activity.get("moving_time"),
|
||||
"elapsed_time": activity.get("elapsed_time"),
|
||||
"total_elevation_gain": activity.get("total_elevation_gain"),
|
||||
"sport_type": activity.get("sport_type"),
|
||||
|
||||
# Distance and time
|
||||
"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_local": activity.get("start_date_local"),
|
||||
"timezone": activity.get("timezone"),
|
||||
"average_speed": activity.get("average_speed"),
|
||||
"max_speed": activity.get("max_speed"),
|
||||
"timezone": normalized_timezone, # Normalized IANA timezone
|
||||
"timezone_raw": strava_timezone, # Original Strava format for reference
|
||||
|
||||
# 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_watts": activity.get("average_watts"),
|
||||
"max_watts": activity.get("max_watts"),
|
||||
"kilojoules": activity.get("kilojoules"),
|
||||
"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"),
|
||||
"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"),
|
||||
"end_latlng": activity.get("end_latlng"),
|
||||
|
||||
# Export links
|
||||
'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',
|
||||
|
||||
# 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')
|
||||
def activities(self, request):
|
||||
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
|
||||
start_date = request.query_params.get('start_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
|
||||
params = {}
|
||||
params = {
|
||||
'per_page': min(int(per_page), 200), # Strava max is 200
|
||||
'page': int(page)
|
||||
}
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
# Parse the date string and convert to Unix timestamp
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
params['after'] = int(start_dt.timestamp())
|
||||
except ValueError:
|
||||
|
@ -234,8 +390,6 @@ class StravaIntegrationView(viewsets.ViewSet):
|
|||
|
||||
if end_date:
|
||||
try:
|
||||
# Parse the date string and convert to Unix timestamp
|
||||
from datetime import datetime
|
||||
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
params['before'] = int(end_dt.timestamp())
|
||||
except ValueError:
|
||||
|
@ -260,7 +414,12 @@ class StravaIntegrationView(viewsets.ViewSet):
|
|||
activities = response.json()
|
||||
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:
|
||||
logger.error(f"Error fetching Strava activities: {str(e)}")
|
||||
|
|
|
@ -285,6 +285,7 @@
|
|||
{#if steps[3].selected}
|
||||
<LocationVisits
|
||||
bind:visits={location.visits}
|
||||
bind:trails={location.trails}
|
||||
objectId={location.id}
|
||||
type="location"
|
||||
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;
|
||||
timezone: string | null;
|
||||
notes: string;
|
||||
activities: Activity[]; // Array of activities associated with the visit
|
||||
}[];
|
||||
collections?: string[] | null;
|
||||
latitude: number | null;
|
||||
|
@ -328,3 +329,87 @@ export type Trail = {
|
|||
wanderer_id?: string | null; // Optional ID for integration with Wanderer
|
||||
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