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(
|
||||||
|
@ -179,33 +216,149 @@ 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>
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Collection } from '$lib/types';
|
import type { Collection, StravaActivity, Trail, Activity } from '$lib/types';
|
||||||
import TimezoneSelector from '../TimezoneSelector.svelte';
|
import TimezoneSelector from '../TimezoneSelector.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
|
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
|
||||||
|
@ -18,6 +18,12 @@
|
||||||
import CheckIcon from '~icons/mdi/check';
|
import CheckIcon from '~icons/mdi/check';
|
||||||
import SettingsIcon from '~icons/mdi/cog';
|
import SettingsIcon from '~icons/mdi/cog';
|
||||||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||||||
|
import RunFastIcon from '~icons/mdi/run-fast';
|
||||||
|
import LoadingIcon from '~icons/mdi/loading';
|
||||||
|
import UploadIcon from '~icons/mdi/upload';
|
||||||
|
import FileIcon from '~icons/mdi/file';
|
||||||
|
import CloseIcon from '~icons/mdi/close';
|
||||||
|
import StravaActivityCard from '../StravaActivityCard.svelte';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
export let collection: Collection | null = null;
|
export let collection: Collection | null = null;
|
||||||
|
@ -29,6 +35,7 @@
|
||||||
export let note: string | null = null;
|
export let note: string | null = null;
|
||||||
export let visits: (Visit | TransportationVisit)[] | null = null;
|
export let visits: (Visit | TransportationVisit)[] | null = null;
|
||||||
export let objectId: string;
|
export let objectId: string;
|
||||||
|
export let trails: Trail[] = [];
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
@ -39,6 +46,7 @@
|
||||||
end_date: string;
|
end_date: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
|
activities?: Activity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type TransportationVisit = {
|
type TransportationVisit = {
|
||||||
|
@ -48,6 +56,7 @@
|
||||||
notes: string;
|
notes: string;
|
||||||
start_timezone: string;
|
start_timezone: string;
|
||||||
end_timezone: string;
|
end_timezone: string;
|
||||||
|
activities?: Activity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Component state
|
// Component state
|
||||||
|
@ -58,7 +67,50 @@
|
||||||
let fullEndDate: string = '';
|
let fullEndDate: string = '';
|
||||||
let constrainDates: boolean = false;
|
let constrainDates: boolean = false;
|
||||||
let isEditing = false;
|
let isEditing = false;
|
||||||
let isExpanded = true;
|
|
||||||
|
// Activity management state
|
||||||
|
let stravaEnabled: boolean = false;
|
||||||
|
let visitActivities: { [visitId: string]: StravaActivity[] } = {};
|
||||||
|
let loadingActivities: { [visitId: string]: boolean } = {};
|
||||||
|
let expandedVisits: { [visitId: string]: boolean } = {};
|
||||||
|
let uploadingActivity: { [visitId: string]: boolean } = {};
|
||||||
|
let showActivityUpload: { [visitId: string]: boolean } = {};
|
||||||
|
|
||||||
|
// Activity form state
|
||||||
|
let activityForm = {
|
||||||
|
name: '',
|
||||||
|
type: 'Run',
|
||||||
|
sport_type: 'Run',
|
||||||
|
distance: null as number | null,
|
||||||
|
moving_time: '',
|
||||||
|
elapsed_time: '',
|
||||||
|
elevation_gain: null as number | null,
|
||||||
|
elevation_loss: null as number | null,
|
||||||
|
start_date: '',
|
||||||
|
calories: null as number | null,
|
||||||
|
notes: '',
|
||||||
|
gpx_file: null as File | null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Activity types for dropdown
|
||||||
|
const activityTypes = [
|
||||||
|
'Run',
|
||||||
|
'Ride',
|
||||||
|
'Swim',
|
||||||
|
'Hike',
|
||||||
|
'Walk',
|
||||||
|
'Workout',
|
||||||
|
'CrossTrain',
|
||||||
|
'Rock Climbing',
|
||||||
|
'Alpine Ski',
|
||||||
|
'Nordic Ski',
|
||||||
|
'Kayaking',
|
||||||
|
'Canoeing',
|
||||||
|
'Rowing',
|
||||||
|
'Golf',
|
||||||
|
'Tennis',
|
||||||
|
'Other'
|
||||||
|
];
|
||||||
|
|
||||||
// Reactive constraints
|
// Reactive constraints
|
||||||
$: constraintStartDate = allDay
|
$: constraintStartDate = allDay
|
||||||
|
@ -116,6 +168,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDuration(duration: string): number {
|
||||||
|
const parts = duration.split(':').map(Number);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function getTypeConfig() {
|
function getTypeConfig() {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'transportation':
|
case 'transportation':
|
||||||
|
@ -199,7 +272,8 @@
|
||||||
end_date: utcEndDate ?? utcStartDate ?? '',
|
end_date: utcEndDate ?? utcStartDate ?? '',
|
||||||
notes: note ?? '',
|
notes: note ?? '',
|
||||||
start_timezone: selectedStartTimezone,
|
start_timezone: selectedStartTimezone,
|
||||||
end_timezone: selectedEndTimezone
|
end_timezone: selectedEndTimezone,
|
||||||
|
activities: []
|
||||||
};
|
};
|
||||||
return transportVisit;
|
return transportVisit;
|
||||||
} else {
|
} else {
|
||||||
|
@ -208,7 +282,8 @@
|
||||||
start_date: utcStartDate ?? '',
|
start_date: utcStartDate ?? '',
|
||||||
end_date: utcEndDate ?? utcStartDate ?? '',
|
end_date: utcEndDate ?? utcStartDate ?? '',
|
||||||
notes: note ?? '',
|
notes: note ?? '',
|
||||||
timezone: selectedStartTimezone
|
timezone: selectedStartTimezone,
|
||||||
|
activities: []
|
||||||
};
|
};
|
||||||
return regularVisit;
|
return regularVisit;
|
||||||
}
|
}
|
||||||
|
@ -217,12 +292,39 @@
|
||||||
async function addVisit() {
|
async function addVisit() {
|
||||||
const newVisit = createVisitObject();
|
const newVisit = createVisitObject();
|
||||||
|
|
||||||
// Add new visit to the visits array
|
// Patch updated visits array to location and get the response with actual IDs
|
||||||
|
if (type === 'location' && objectId) {
|
||||||
|
try {
|
||||||
|
const updatedVisits = visits ? [...visits, newVisit] : [newVisit];
|
||||||
|
|
||||||
|
const response = await fetch(`/api/locations/${objectId}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ visits: updatedVisits })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const updatedLocation = await response.json();
|
||||||
|
// Update visits with the response data that contains actual IDs
|
||||||
|
visits = updatedLocation.visits;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to patch visits:', await response.text());
|
||||||
|
return; // Don't update local state if API call failed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error patching visits:', error);
|
||||||
|
return; // Don't update local state if API call failed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for non-location types - add new visit to the visits array
|
||||||
if (visits) {
|
if (visits) {
|
||||||
visits = [...visits, newVisit];
|
visits = [...visits, newVisit];
|
||||||
} else {
|
} else {
|
||||||
visits = [newVisit];
|
visits = [newVisit];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset form fields
|
// Reset form fields
|
||||||
note = '';
|
note = '';
|
||||||
|
@ -230,25 +332,315 @@
|
||||||
localEndDate = '';
|
localEndDate = '';
|
||||||
utcStartDate = null;
|
utcStartDate = null;
|
||||||
utcEndDate = null;
|
utcEndDate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity management functions
|
||||||
|
async function loadActivitiesForVisit(visit: Visit | TransportationVisit) {
|
||||||
|
if (!stravaEnabled) return;
|
||||||
|
|
||||||
|
loadingActivities[visit.id] = true;
|
||||||
|
loadingActivities = { ...loadingActivities };
|
||||||
|
|
||||||
// Patch updated visits array to location
|
|
||||||
if (type === 'location' && objectId) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/locations/${objectId}/`, {
|
let startDate = new Date(visit.start_date);
|
||||||
|
let endDate = new Date(visit.end_date);
|
||||||
|
|
||||||
|
if (isAllDay(visit.start_date) && visit.end_date.includes('T00:00:00')) {
|
||||||
|
endDate = new Date(visit.end_date.replace('T00:00:00', 'T23:59:59'));
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate.setHours(startDate.getHours() - 12);
|
||||||
|
endDate.setHours(endDate.getHours() + 12);
|
||||||
|
|
||||||
|
const bufferedStart = startDate.toISOString();
|
||||||
|
const bufferedEnd = endDate.toISOString();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/integrations/strava/activities/?start_date=${bufferedStart}&end_date=${bufferedEnd}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const apiRes = await response.json();
|
||||||
|
const filtered = apiRes.activities;
|
||||||
|
visitActivities[visit.id] = filtered;
|
||||||
|
visitActivities = { ...visitActivities };
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load activities for visit:', await response.text());
|
||||||
|
visitActivities[visit.id] = [];
|
||||||
|
visitActivities = { ...visitActivities };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading activities for visit:', error);
|
||||||
|
visitActivities[visit.id] = [];
|
||||||
|
visitActivities = { ...visitActivities };
|
||||||
|
} finally {
|
||||||
|
loadingActivities[visit.id] = false;
|
||||||
|
loadingActivities = { ...loadingActivities };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVisitActivities(visit: Visit | TransportationVisit) {
|
||||||
|
const isExpanded = expandedVisits[visit.id];
|
||||||
|
|
||||||
|
if (!isExpanded) {
|
||||||
|
expandedVisits[visit.id] = true;
|
||||||
|
expandedVisits = { ...expandedVisits };
|
||||||
|
|
||||||
|
if (!visitActivities[visit.id]) {
|
||||||
|
loadActivitiesForVisit(visit);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expandedVisits[visit.id] = false;
|
||||||
|
expandedVisits = { ...expandedVisits };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showActivityUploadForm(visitId: string) {
|
||||||
|
showActivityUpload[visitId] = true;
|
||||||
|
showActivityUpload = { ...showActivityUpload };
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
activityForm = {
|
||||||
|
name: '',
|
||||||
|
type: 'Run',
|
||||||
|
sport_type: 'Run',
|
||||||
|
distance: null,
|
||||||
|
moving_time: '',
|
||||||
|
elapsed_time: '',
|
||||||
|
elevation_gain: null,
|
||||||
|
elevation_loss: null,
|
||||||
|
start_date: '',
|
||||||
|
calories: null,
|
||||||
|
notes: '',
|
||||||
|
gpx_file: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideActivityUploadForm(visitId: string) {
|
||||||
|
showActivityUpload[visitId] = false;
|
||||||
|
showActivityUpload = { ...showActivityUpload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGpxFileChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (target.files && target.files[0]) {
|
||||||
|
activityForm.gpx_file = target.files[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadActivity(visitId: string) {
|
||||||
|
if (!activityForm.name.trim()) {
|
||||||
|
alert('Activity name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingActivity[visitId] = true;
|
||||||
|
uploadingActivity = { ...uploadingActivity };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Add basic activity data
|
||||||
|
formData.append('visit', visitId);
|
||||||
|
formData.append('name', activityForm.name);
|
||||||
|
formData.append('type', activityForm.type);
|
||||||
|
if (activityForm.sport_type) formData.append('sport_type', activityForm.sport_type);
|
||||||
|
if (activityForm.distance) formData.append('distance', activityForm.distance.toString());
|
||||||
|
if (activityForm.moving_time) {
|
||||||
|
const seconds = parseDuration(activityForm.moving_time);
|
||||||
|
formData.append('moving_time', `PT${seconds}S`);
|
||||||
|
}
|
||||||
|
if (activityForm.elapsed_time) {
|
||||||
|
const seconds = parseDuration(activityForm.elapsed_time);
|
||||||
|
formData.append('elapsed_time', `PT${seconds}S`);
|
||||||
|
}
|
||||||
|
if (activityForm.elevation_gain)
|
||||||
|
formData.append('elevation_gain', activityForm.elevation_gain.toString());
|
||||||
|
if (activityForm.elevation_loss)
|
||||||
|
formData.append('elevation_loss', activityForm.elevation_loss.toString());
|
||||||
|
if (activityForm.start_date)
|
||||||
|
formData.append('start_date', new Date(activityForm.start_date).toISOString());
|
||||||
|
if (activityForm.calories) formData.append('calories', activityForm.calories.toString());
|
||||||
|
|
||||||
|
// Add GPX file if provided
|
||||||
|
if (activityForm.gpx_file) {
|
||||||
|
formData.append('gpx_file', activityForm.gpx_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/activities/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const newActivity = await response.json();
|
||||||
|
|
||||||
|
// Update the visit's activities array
|
||||||
|
if (visits) {
|
||||||
|
visits = visits.map((visit) => {
|
||||||
|
if (visit.id === visitId) {
|
||||||
|
return {
|
||||||
|
...visit,
|
||||||
|
activities: [...(visit.activities || []), newActivity]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return visit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the upload form
|
||||||
|
hideActivityUploadForm(visitId);
|
||||||
|
|
||||||
|
// Update the location with new visits data
|
||||||
|
if (type === 'location' && objectId) {
|
||||||
|
await fetch(`/api/locations/${objectId}/`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ visits }) // Send updated visits array
|
body: JSON.stringify({ visits })
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (!response.ok) {
|
} else {
|
||||||
console.error('Failed to patch visits:', await response.text());
|
const errorText = await response.text();
|
||||||
|
console.error('Failed to upload activity:', errorText);
|
||||||
|
alert('Failed to upload activity. Please try again.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error patching visits:', error);
|
console.error('Error uploading activity:', error);
|
||||||
|
alert('Error uploading activity. Please try again.');
|
||||||
|
} finally {
|
||||||
|
uploadingActivity[visitId] = false;
|
||||||
|
uploadingActivity = { ...uploadingActivity };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteActivity(visitId: string, activityId: string) {
|
||||||
|
if (!confirm('Are you sure you want to delete this activity?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/activities/${activityId}/`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Update the visit's activities array
|
||||||
|
if (visits) {
|
||||||
|
visits = visits.map((visit) => {
|
||||||
|
if (visit.id === visitId) {
|
||||||
|
return {
|
||||||
|
...visit,
|
||||||
|
activities: (visit.activities || []).filter((a) => a.id !== activityId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return visit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the location with new visits data
|
||||||
|
if (type === 'location' && objectId) {
|
||||||
|
await fetch(`/api/locations/${objectId}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ visits })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Failed to delete activity:', await response.text());
|
||||||
|
alert('Failed to delete activity. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting activity:', error);
|
||||||
|
alert('Error deleting activity. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStravaActivityImport(event: CustomEvent<StravaActivity>, visitId: string) {
|
||||||
|
const stravaActivity = event.detail;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert Strava activity to our activity format
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('visit', visitId);
|
||||||
|
formData.append('name', stravaActivity.name);
|
||||||
|
formData.append('type', stravaActivity.type);
|
||||||
|
formData.append('sport_type', stravaActivity.sport_type || stravaActivity.type);
|
||||||
|
|
||||||
|
// Convert distance from meters to kilometers
|
||||||
|
if (stravaActivity.distance) {
|
||||||
|
formData.append('distance', (stravaActivity.distance / 1000).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert time to ISO duration format
|
||||||
|
if (stravaActivity.moving_time) {
|
||||||
|
formData.append('moving_time', `PT${stravaActivity.moving_time}S`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stravaActivity.elapsed_time) {
|
||||||
|
formData.append('elapsed_time', `PT${stravaActivity.elapsed_time}S`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add elevation data
|
||||||
|
if (stravaActivity.total_elevation_gain) {
|
||||||
|
formData.append('elevation_gain', stravaActivity.total_elevation_gain.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stravaActivity.estimated_elevation_loss) {
|
||||||
|
formData.append('elevation_loss', stravaActivity.estimated_elevation_loss.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add start date
|
||||||
|
if (stravaActivity.start_date) {
|
||||||
|
formData.append('start_date', stravaActivity.start_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add calories if available
|
||||||
|
if (stravaActivity.calories) {
|
||||||
|
formData.append('calories', stravaActivity.calories.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add external service ID to track the Strava origin
|
||||||
|
formData.append('external_service_id', stravaActivity.id.toString());
|
||||||
|
|
||||||
|
const response = await fetch('/api/activities/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const newActivity = await response.json();
|
||||||
|
|
||||||
|
// Update the visit's activities array
|
||||||
|
if (visits) {
|
||||||
|
visits = visits.map((visit) => {
|
||||||
|
if (visit.id === visitId) {
|
||||||
|
return {
|
||||||
|
...visit,
|
||||||
|
activities: [...(visit.activities || []), newActivity]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return visit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`Activity "${stravaActivity.name}" imported successfully`);
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Failed to import Strava activity:', errorText);
|
||||||
|
alert('Failed to import activity. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing Strava activity:', error);
|
||||||
|
alert('Error importing activity. Please try again.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function editVisit(visit: Visit | TransportationVisit) {
|
function editVisit(visit: Visit | TransportationVisit) {
|
||||||
|
@ -282,6 +674,12 @@
|
||||||
visits = visits.filter((v) => v.id !== visit.id);
|
visits = visits.filter((v) => v.id !== visit.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up activities for this visit
|
||||||
|
delete visitActivities[visit.id];
|
||||||
|
delete expandedVisits[visit.id];
|
||||||
|
delete loadingActivities[visit.id];
|
||||||
|
delete showActivityUpload[visit.id];
|
||||||
|
|
||||||
note = visit.notes;
|
note = visit.notes;
|
||||||
constrainDates = true;
|
constrainDates = true;
|
||||||
utcStartDate = visit.start_date;
|
utcStartDate = visit.start_date;
|
||||||
|
@ -298,7 +696,7 @@
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ visits }) // Send updated visits array
|
body: JSON.stringify({ visits })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -308,6 +706,12 @@
|
||||||
visits = visits.filter((v) => v.id !== visitId);
|
visits = visits.filter((v) => v.id !== visitId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up activities for this visit
|
||||||
|
delete visitActivities[visitId];
|
||||||
|
delete expandedVisits[visitId];
|
||||||
|
delete loadingActivities[visitId];
|
||||||
|
delete showActivityUpload[visitId];
|
||||||
|
|
||||||
// Patch updated visits array to location
|
// Patch updated visits array to location
|
||||||
if (type === 'location' && objectId) {
|
if (type === 'location' && objectId) {
|
||||||
fetch(`/api/locations/${objectId}/`, {
|
fetch(`/api/locations/${objectId}/`, {
|
||||||
|
@ -315,7 +719,7 @@
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ visits }) // Send updated visits array
|
body: JSON.stringify({ visits })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -350,12 +754,27 @@
|
||||||
if (!selectedEndTimezone) {
|
if (!selectedEndTimezone) {
|
||||||
selectedEndTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
selectedEndTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if Strava is enabled by making a simple API call
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/integrations/strava/activities', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stravaEnabled = response.ok;
|
||||||
|
} catch {
|
||||||
|
stravaEnabled = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$: typeConfig = getTypeConfig();
|
$: typeConfig = getTypeConfig();
|
||||||
$: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid;
|
$: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
|
||||||
|
<div class="max-w-full mx-auto space-y-6">
|
||||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
@ -366,12 +785,8 @@
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-bold">{$t('adventures.date_information')}</h2>
|
<h2 class="text-xl font-bold">{$t('adventures.date_information')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" on:click={() => (isExpanded = !isExpanded)}>
|
|
||||||
{isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isExpanded}
|
|
||||||
<!-- Settings Section -->
|
<!-- Settings Section -->
|
||||||
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
|
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
@ -421,7 +836,8 @@
|
||||||
{#if collection?.start_date && collection?.end_date}
|
{#if collection?.start_date && collection?.end_date}
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CalendarIcon class="w-4 h-4 text-base-content/70" />
|
<CalendarIcon class="w-4 h-4 text-base-content/70" />
|
||||||
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
|
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="toggle toggle-{typeConfig.color} toggle-sm"
|
class="toggle toggle-{typeConfig.color} toggle-sm"
|
||||||
|
@ -583,10 +999,42 @@
|
||||||
"{visit.notes}"
|
"{visit.notes}"
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if visit.activities && visit.activities.length > 0}
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<RunFastIcon class="w-3 h-3 text-success" />
|
||||||
|
<span class="text-xs text-success font-medium">
|
||||||
|
{visit.activities.length} saved activities
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Visit Actions -->
|
<!-- Visit Actions -->
|
||||||
<div class="flex gap-1 ml-4">
|
<div class="flex gap-1 ml-4">
|
||||||
|
<!-- Activities Button (only show if Strava is enabled) -->
|
||||||
|
{#if stravaEnabled}
|
||||||
|
<button
|
||||||
|
class="btn btn-info btn-xs tooltip tooltip-top gap-1"
|
||||||
|
data-tip="View Strava Activities"
|
||||||
|
on:click={() => toggleVisitActivities(visit)}
|
||||||
|
>
|
||||||
|
<RunFastIcon class="w-3 h-3" />
|
||||||
|
{#if visitActivities[visit.id]}
|
||||||
|
({visitActivities[visit.id].length})
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Upload Activity Button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-success btn-xs tooltip tooltip-top gap-1"
|
||||||
|
data-tip="Add Activity"
|
||||||
|
on:click={() => showActivityUploadForm(visit.id)}
|
||||||
|
>
|
||||||
|
<UploadIcon class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-warning btn-xs tooltip tooltip-top"
|
class="btn btn-warning btn-xs tooltip tooltip-top"
|
||||||
data-tip="Edit Visit"
|
data-tip="Edit Visit"
|
||||||
|
@ -603,13 +1051,305 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Upload Form -->
|
||||||
|
{#if showActivityUpload[visit.id]}
|
||||||
|
<div class="mt-4 pt-4 border-t border-base-300">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UploadIcon class="w-4 h-4 text-success" />
|
||||||
|
<h4 class="font-medium text-sm">Add New Activity</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
on:click={() => hideActivityUploadForm(visit.id)}
|
||||||
|
>
|
||||||
|
<CloseIcon class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-base-200/50 p-4 rounded-lg">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Activity Name -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="label-text text-xs font-medium">Activity Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Morning Run"
|
||||||
|
bind:value={activityForm.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Type -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium">Type</label>
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-sm w-full mt-1"
|
||||||
|
bind:value={activityForm.type}
|
||||||
|
>
|
||||||
|
{#each activityTypes as activityType}
|
||||||
|
<option value={activityType}>{activityType}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sport Type -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium">Sport Type</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Trail Running"
|
||||||
|
bind:value={activityForm.sport_type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distance -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium">Distance (km)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="5.2"
|
||||||
|
bind:value={activityForm.distance}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Moving Time -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium"
|
||||||
|
>Moving Time (HH:MM:SS)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="0:25:30"
|
||||||
|
bind:value={activityForm.moving_time}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Elapsed Time -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium"
|
||||||
|
>Elapsed Time (HH:MM:SS)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="0:30:00"
|
||||||
|
bind:value={activityForm.elapsed_time}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Date -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium">Start Date</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
bind:value={activityForm.start_date}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Elevation Gain -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium"
|
||||||
|
>Elevation Gain (m)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="150"
|
||||||
|
bind:value={activityForm.elevation_gain}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Elevation Loss -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium"
|
||||||
|
>Elevation Loss (m)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="150"
|
||||||
|
bind:value={activityForm.elevation_loss}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calories -->
|
||||||
|
<div>
|
||||||
|
<label class="label-text text-xs font-medium">Calories</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="300"
|
||||||
|
bind:value={activityForm.calories}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GPX File -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="label-text text-xs font-medium">GPX File</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".gpx"
|
||||||
|
class="file-input file-input-bordered file-input-sm w-full mt-1"
|
||||||
|
on:change={handleGpxFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
on:click={() => hideActivityUploadForm(visit.id)}
|
||||||
|
disabled={uploadingActivity[visit.id]}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success btn-sm gap-2"
|
||||||
|
on:click={() => uploadActivity(visit.id)}
|
||||||
|
disabled={uploadingActivity[visit.id] || !activityForm.name.trim()}
|
||||||
|
>
|
||||||
|
{#if uploadingActivity[visit.id]}
|
||||||
|
<LoadingIcon class="w-3 h-3 animate-spin" />
|
||||||
|
Uploading...
|
||||||
|
{:else}
|
||||||
|
<UploadIcon class="w-3 h-3" />
|
||||||
|
Upload Activity
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Saved Activities Section -->
|
||||||
|
{#if visit.activities && visit.activities.length > 0}
|
||||||
|
<div class="mt-4 pt-4 border-t border-base-300">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<RunFastIcon class="w-4 h-4 text-success" />
|
||||||
|
<h4 class="font-medium text-sm">
|
||||||
|
Saved Activities ({visit.activities.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each visit.activities as activity (activity.id)}
|
||||||
|
<div class="bg-base-200/50 p-3 rounded-lg">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<RunFastIcon class="w-3 h-3 text-success flex-shrink-0" />
|
||||||
|
<h5 class="font-medium text-sm truncate">{activity.name}</h5>
|
||||||
|
<span class="badge badge-outline badge-xs">{activity.type}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-base-content/70 space-y-1">
|
||||||
|
{#if activity.distance}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span>Distance: {activity.distance} km</span>
|
||||||
|
{#if activity.moving_time}
|
||||||
|
<span>Time: {activity.moving_time}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activity.elevation_gain || activity.elevation_loss}
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if activity.elevation_gain}
|
||||||
|
<span>↗ {activity.elevation_gain}m</span>
|
||||||
|
{/if}
|
||||||
|
{#if activity.elevation_loss}
|
||||||
|
<span>↘ {activity.elevation_loss}m</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activity.start_date}
|
||||||
|
<div>
|
||||||
|
Started: {new Date(activity.start_date).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activity.gpx_file}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<FileIcon class="w-3 h-3" />
|
||||||
|
<a
|
||||||
|
href={activity.gpx_file}
|
||||||
|
target="_blank"
|
||||||
|
class="link link-primary"
|
||||||
|
>
|
||||||
|
View GPX
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-error btn-xs tooltip tooltip-top ml-2"
|
||||||
|
data-tip="Delete Activity"
|
||||||
|
on:click={() => deleteActivity(visit.id, activity.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Strava Activities Section -->
|
||||||
|
{#if stravaEnabled && expandedVisits[visit.id]}
|
||||||
|
<div class="mt-4 pt-4 border-t border-base-300">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<RunFastIcon class="w-4 h-4 text-info" />
|
||||||
|
<h4 class="font-medium text-sm">Strava Activities During Visit</h4>
|
||||||
|
{#if loadingActivities[visit.id]}
|
||||||
|
<LoadingIcon class="w-4 h-4 animate-spin text-info" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadingActivities[visit.id]}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="loading loading-spinner loading-sm"></div>
|
||||||
|
<p class="text-xs text-base-content/60 mt-2">Loading activities...</p>
|
||||||
|
</div>
|
||||||
|
{:else if visitActivities[visit.id] && visitActivities[visit.id].length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each visitActivities[visit.id] as activity (activity.id)}
|
||||||
|
<div class="pl-4">
|
||||||
|
<StravaActivityCard
|
||||||
|
{activity}
|
||||||
|
on:import={(event) => handleStravaActivityImport(event, visit.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center py-4 text-base-content/60">
|
||||||
|
<div class="text-2xl mb-2">🏃♂️</div>
|
||||||
|
<p class="text-xs">No Strava activities found during this visit</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 justify-end pt-4">
|
<div class="flex gap-3 justify-end pt-4">
|
||||||
<button class="btn btn-neutral-200 gap-2" on:click={handleBack}>
|
<button class="btn btn-neutral-200 gap-2" on:click={handleBack}>
|
||||||
<ArrowLeftIcon class="w-5 h-5" />
|
<ArrowLeftIcon class="w-5 h-5" />
|
||||||
|
|
|
@ -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