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
|
||||
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 .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
|
||||
|
||||
|
@ -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
|
@ -619,3 +619,58 @@ class Trail(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
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':
|
||||
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
|
||||
|
|
|
@ -16,3 +16,4 @@ from .lodging_view import *
|
|||
from .recommendations_view import *
|
||||
from .import_export_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(
|
||||
|
@ -179,33 +216,149 @@ class StravaIntegrationView(viewsets.ViewSet):
|
|||
return strava_token, None
|
||||
|
||||
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 {
|
||||
# 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>
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type { Collection } from '$lib/types';
|
||||
import type { Collection, StravaActivity, Trail, Activity } from '$lib/types';
|
||||
import TimezoneSelector from '../TimezoneSelector.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
|
||||
|
@ -18,6 +18,12 @@
|
|||
import CheckIcon from '~icons/mdi/check';
|
||||
import SettingsIcon from '~icons/mdi/cog';
|
||||
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
|
||||
export let collection: Collection | null = null;
|
||||
|
@ -29,6 +35,7 @@
|
|||
export let note: string | null = null;
|
||||
export let visits: (Visit | TransportationVisit)[] | null = null;
|
||||
export let objectId: string;
|
||||
export let trails: Trail[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
@ -39,6 +46,7 @@
|
|||
end_date: string;
|
||||
notes: string;
|
||||
timezone: string | null;
|
||||
activities?: Activity[];
|
||||
};
|
||||
|
||||
type TransportationVisit = {
|
||||
|
@ -48,6 +56,7 @@
|
|||
notes: string;
|
||||
start_timezone: string;
|
||||
end_timezone: string;
|
||||
activities?: Activity[];
|
||||
};
|
||||
|
||||
// Component state
|
||||
|
@ -58,7 +67,50 @@
|
|||
let fullEndDate: string = '';
|
||||
let constrainDates: boolean = 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
|
||||
$: 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() {
|
||||
switch (type) {
|
||||
case 'transportation':
|
||||
|
@ -199,7 +272,8 @@
|
|||
end_date: utcEndDate ?? utcStartDate ?? '',
|
||||
notes: note ?? '',
|
||||
start_timezone: selectedStartTimezone,
|
||||
end_timezone: selectedEndTimezone
|
||||
end_timezone: selectedEndTimezone,
|
||||
activities: []
|
||||
};
|
||||
return transportVisit;
|
||||
} else {
|
||||
|
@ -208,7 +282,8 @@
|
|||
start_date: utcStartDate ?? '',
|
||||
end_date: utcEndDate ?? utcStartDate ?? '',
|
||||
notes: note ?? '',
|
||||
timezone: selectedStartTimezone
|
||||
timezone: selectedStartTimezone,
|
||||
activities: []
|
||||
};
|
||||
return regularVisit;
|
||||
}
|
||||
|
@ -217,12 +292,39 @@
|
|||
async function addVisit() {
|
||||
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) {
|
||||
visits = [...visits, newVisit];
|
||||
} else {
|
||||
visits = [newVisit];
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form fields
|
||||
note = '';
|
||||
|
@ -230,25 +332,315 @@
|
|||
localEndDate = '';
|
||||
utcStartDate = 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 {
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ visits }) // Send updated visits array
|
||||
body: JSON.stringify({ visits })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to patch visits:', await response.text());
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('Failed to upload activity:', errorText);
|
||||
alert('Failed to upload activity. Please try again.');
|
||||
}
|
||||
} 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) {
|
||||
|
@ -282,6 +674,12 @@
|
|||
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;
|
||||
constrainDates = true;
|
||||
utcStartDate = visit.start_date;
|
||||
|
@ -298,7 +696,7 @@
|
|||
headers: {
|
||||
'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);
|
||||
}
|
||||
|
||||
// Clean up activities for this visit
|
||||
delete visitActivities[visitId];
|
||||
delete expandedVisits[visitId];
|
||||
delete loadingActivities[visitId];
|
||||
delete showActivityUpload[visitId];
|
||||
|
||||
// Patch updated visits array to location
|
||||
if (type === 'location' && objectId) {
|
||||
fetch(`/api/locations/${objectId}/`, {
|
||||
|
@ -315,7 +719,7 @@
|
|||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ visits }) // Send updated visits array
|
||||
body: JSON.stringify({ visits })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -350,12 +754,27 @@
|
|||
if (!selectedEndTimezone) {
|
||||
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();
|
||||
$: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid;
|
||||
</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-body p-6">
|
||||
<!-- Header -->
|
||||
|
@ -366,12 +785,8 @@
|
|||
</div>
|
||||
<h2 class="text-xl font-bold">{$t('adventures.date_information')}</h2>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" on:click={() => (isExpanded = !isExpanded)}>
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isExpanded}
|
||||
<!-- Settings Section -->
|
||||
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
|
@ -421,7 +836,8 @@
|
|||
{#if collection?.start_date && collection?.end_date}
|
||||
<div class="flex items-center gap-3">
|
||||
<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
|
||||
type="checkbox"
|
||||
class="toggle toggle-{typeConfig.color} toggle-sm"
|
||||
|
@ -583,10 +999,42 @@
|
|||
"{visit.notes}"
|
||||
</p>
|
||||
{/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>
|
||||
|
||||
<!-- Visit Actions -->
|
||||
<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
|
||||
class="btn btn-warning btn-xs tooltip tooltip-top"
|
||||
data-tip="Edit Visit"
|
||||
|
@ -603,13 +1051,305 @@
|
|||
</button>
|
||||
</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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<button class="btn btn-neutral-200 gap-2" on:click={handleBack}>
|
||||
<ArrowLeftIcon class="w-5 h-5" />
|
||||
|
|
|
@ -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