1
0
Fork 0
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:
Sean Morley 2025-08-03 13:41:57 -04:00
parent c7ff8f4bc7
commit 5046bd49f7
13 changed files with 1626 additions and 255 deletions

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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 *

View 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)

View file

@ -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)}")

View file

@ -285,6 +285,7 @@
{#if steps[3].selected}
<LocationVisits
bind:visits={location.visits}
bind:trails={location.trails}
objectId={location.id}
type="location"
on:back={() => {

View 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>&nbsp;{activity.average_cadence.toFixed(1)}
</div>
{/if}
{#if activity.calories}
<div class="badge badge-ghost">
<span class="font-medium">Calories:</span>&nbsp;{activity.calories}
</div>
{/if}
{#if activity.kudos_count > 0}
<div class="badge badge-ghost">
<span class="font-medium">Kudos:</span>&nbsp;{activity.kudos_count}
</div>
{/if}
{#if activity.achievement_count > 0}
<div class="badge badge-success badge-outline">
<span class="font-medium">Achievements:</span>&nbsp;{activity.achievement_count}
</div>
{/if}
{#if activity.pr_count > 0}
<div class="badge badge-warning badge-outline">
<span class="font-medium">PRs:</span>&nbsp;{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>

View file

@ -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" />

View file

@ -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;
};