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,8 +1,8 @@
import os
from django.contrib import admin
from django.utils.html import mark_safe
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login
admin.autodiscover()
@ -165,6 +165,7 @@ admin.site.register(ContentAttachment)
admin.site.register(Lodging)
admin.site.register(CollectionInvite, CollectionInviteAdmin)
admin.site.register(Trail)
admin.site.register(Activity)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'

File diff suppressed because one or more lines are too long

View file

@ -618,4 +618,59 @@ class Trail(models.Model):
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
class Activity(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user)
visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name='activities')
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, related_name='activities', blank=True, null=True)
# GPX File
gpx_file = models.FileField(upload_to=PathAndRename('activities/'), validators=[validate_file_extension], blank=True, null=True)
# Descriptive
name = models.CharField(max_length=200)
type = models.CharField(max_length=100, default='general') # E.g., Run, Hike, Bike
sport_type = models.CharField(max_length=100, blank=True, null=True) # Optional detailed type
# Time & Distance
distance = models.FloatField(blank=True, null=True) # in meters
moving_time = models.DurationField(blank=True, null=True)
elapsed_time = models.DurationField(blank=True, null=True)
rest_time = models.DurationField(blank=True, null=True)
# Elevation
elevation_gain = models.FloatField(blank=True, null=True) # in meters
elevation_loss = models.FloatField(blank=True, null=True) # estimated
elev_high = models.FloatField(blank=True, null=True)
elev_low = models.FloatField(blank=True, null=True)
# Timing
start_date = models.DateTimeField(blank=True, null=True)
start_date_local = models.DateTimeField(blank=True, null=True)
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], blank=True, null=True)
# Speed
average_speed = models.FloatField(blank=True, null=True) # in m/s
max_speed = models.FloatField(blank=True, null=True) # in m/s
# Optional metrics
average_cadence = models.FloatField(blank=True, null=True)
calories = models.FloatField(blank=True, null=True)
# Coordinates
start_lat = models.FloatField(blank=True, null=True)
start_lng = models.FloatField(blank=True, null=True)
end_lat = models.FloatField(blank=True, null=True)
end_lng = models.FloatField(blank=True, null=True)
# Optional links
external_service_id = models.CharField(max_length=100, blank=True, null=True) # E.g., Strava ID
def __str__(self):
return f"{self.name} ({self.type})"
class Meta:
verbose_name = "Activity"
verbose_name_plural = "Activities"

View file

@ -111,7 +111,12 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
# If the object has a location field, get that location and continue checking with that object, basically from the location's perspective. I am very proud of this line of code and that's why I am writing this comment.
if type(obj).__name__ == 'Trail':
obj = obj.location
if type(obj).__name__ == 'Activity':
# If the object is an Activity, get its location
if hasattr(obj, 'visit') and hasattr(obj.visit, 'location'):
obj = obj.visit.location
# Anonymous users only get read access to public objects
if not user or not user.is_authenticated:
return is_safe_method and getattr(obj, 'is_public', False)

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

@ -15,4 +15,5 @@ from .attachment_view import *
from .lodging_view import *
from .recommendations_view import *
from .import_export_view import *
from .trail_view import *
from .trail_view import *
from .activity_view import *

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(
@ -178,34 +215,150 @@ class StravaIntegrationView(viewsets.ViewSet):
return strava_token, None
def extract_essential_activity_info(self,activity):
# Pick only essential fields from a single activity dict
def extract_essential_activity_info(self, activity):
"""
Extract essential fields from a single activity dict with enhanced metrics
"""
# Calculate additional elevation metrics
elev_high = activity.get("elev_high")
elev_low = activity.get("elev_low")
total_elevation_gain = activity.get("total_elevation_gain", 0)
# Calculate total elevation loss (approximate)
total_elevation_range = None
estimated_elevation_loss = None
if elev_high is not None and elev_low is not None:
total_elevation_range = elev_high - elev_low
estimated_elevation_loss = max(0, total_elevation_range - total_elevation_gain)
# Calculate pace metrics
moving_time = activity.get("moving_time")
distance = activity.get("distance")
pace_per_km = None
pace_per_mile = None
if moving_time and distance and distance > 0:
pace_per_km = moving_time / (distance / 1000)
pace_per_mile = moving_time / (distance / 1609.34)
# Calculate efficiency metrics
grade_adjusted_speed = None
if activity.get("splits_metric") and len(activity.get("splits_metric", [])) > 0:
splits = activity.get("splits_metric", [])
grade_speeds = [split.get("average_grade_adjusted_speed") for split in splits if split.get("average_grade_adjusted_speed")]
if grade_speeds:
grade_adjusted_speed = sum(grade_speeds) / len(grade_speeds)
# Calculate time metrics
elapsed_time = activity.get("elapsed_time")
moving_time = activity.get("moving_time")
rest_time = None
if elapsed_time and moving_time:
rest_time = elapsed_time - moving_time
# Extract and normalize timezone
strava_timezone = activity.get("timezone")
normalized_timezone = self.extract_timezone_from_strava(strava_timezone)
return {
# Basic activity info
"id": activity.get("id"),
"name": activity.get("name"),
"type": activity.get("type"),
"distance": activity.get("distance"),
"moving_time": activity.get("moving_time"),
"elapsed_time": activity.get("elapsed_time"),
"total_elevation_gain": activity.get("total_elevation_gain"),
"sport_type": activity.get("sport_type"),
# Distance and time
"distance": activity.get("distance"), # meters
"distance_km": round(activity.get("distance", 0) / 1000, 2) if activity.get("distance") else None,
"distance_miles": round(activity.get("distance", 0) / 1609.34, 2) if activity.get("distance") else None,
"moving_time": activity.get("moving_time"), # seconds
"elapsed_time": activity.get("elapsed_time"), # seconds
"rest_time": rest_time, # seconds of non-moving time
# Enhanced elevation metrics
"total_elevation_gain": activity.get("total_elevation_gain"), # meters
"estimated_elevation_loss": estimated_elevation_loss, # meters (estimated)
"elev_high": activity.get("elev_high"), # highest point in meters
"elev_low": activity.get("elev_low"), # lowest point in meters
"total_elevation_range": total_elevation_range, # difference between high and low
# Date and location
"start_date": activity.get("start_date"),
"start_date_local": activity.get("start_date_local"),
"timezone": activity.get("timezone"),
"average_speed": activity.get("average_speed"),
"max_speed": activity.get("max_speed"),
"timezone": normalized_timezone, # Normalized IANA timezone
"timezone_raw": strava_timezone, # Original Strava format for reference
# Speed and pace metrics
"average_speed": activity.get("average_speed"), # m/s
"average_speed_kmh": round(activity.get("average_speed", 0) * 3.6, 2) if activity.get("average_speed") else None,
"average_speed_mph": round(activity.get("average_speed", 0) * 2.237, 2) if activity.get("average_speed") else None,
"max_speed": activity.get("max_speed"), # m/s
"max_speed_kmh": round(activity.get("max_speed", 0) * 3.6, 2) if activity.get("max_speed") else None,
"max_speed_mph": round(activity.get("max_speed", 0) * 2.237, 2) if activity.get("max_speed") else None,
"pace_per_km_seconds": pace_per_km, # seconds per km
"pace_per_mile_seconds": pace_per_mile, # seconds per mile
"grade_adjusted_average_speed": grade_adjusted_speed, # m/s accounting for elevation
# Performance metrics
"average_cadence": activity.get("average_cadence"),
"average_watts": activity.get("average_watts"),
"max_watts": activity.get("max_watts"),
"kilojoules": activity.get("kilojoules"),
"calories": activity.get("calories"),
# Achievement metrics
"achievement_count": activity.get("achievement_count"),
"kudos_count": activity.get("kudos_count"),
"comment_count": activity.get("comment_count"),
"pr_count": activity.get("pr_count"), # personal records achieved
# Equipment and technical
"gear_id": activity.get("gear_id"),
"device_name": activity.get("device_name"),
"trainer": activity.get("trainer"), # indoor trainer activity
"manual": activity.get("manual"), # manually entered
# GPS coordinates
"start_latlng": activity.get("start_latlng"),
"end_latlng": activity.get("end_latlng"),
# Export links
'export_original': f'https://www.strava.com/activities/{activity.get("id")}/export_original',
'export_gpx': f'https://www.strava.com/activities/{activity.get("id")}/export_gpx',
# Additional useful fields
"visibility": activity.get("visibility"),
"photo_count": activity.get("photo_count"),
"has_heartrate": activity.get("has_heartrate"),
"flagged": activity.get("flagged"),
"commute": activity.get("commute"),
}
@staticmethod
def format_pace_readable(pace_seconds):
"""
Helper function to convert pace in seconds to readable format (MM:SS)
"""
if pace_seconds is None:
return None
minutes = int(pace_seconds // 60)
seconds = int(pace_seconds % 60)
return f"{minutes}:{seconds:02d}"
@staticmethod
def format_time_readable(time_seconds):
"""
Helper function to convert time in seconds to readable format (HH:MM:SS)
"""
if time_seconds is None:
return None
hours = int(time_seconds // 3600)
minutes = int((time_seconds % 3600) // 60)
seconds = int(time_seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
else:
return f"{minutes}:{seconds:02d}"
@action(detail=False, methods=['get'], url_path='activities')
def activities(self, request):
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
@ -215,14 +368,17 @@ class StravaIntegrationView(viewsets.ViewSet):
# Get date parameters from query string
start_date = request.query_params.get('start_date')
end_date = request.query_params.get('end_date')
per_page = request.query_params.get('per_page', 30) # Default to 30 activities
page = request.query_params.get('page', 1)
# Build query parameters for Strava API
params = {}
params = {
'per_page': min(int(per_page), 200), # Strava max is 200
'page': int(page)
}
if start_date:
try:
# Parse the date string and convert to Unix timestamp
from datetime import datetime
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
params['after'] = int(start_dt.timestamp())
except ValueError:
@ -234,8 +390,6 @@ class StravaIntegrationView(viewsets.ViewSet):
if end_date:
try:
# Parse the date string and convert to Unix timestamp
from datetime import datetime
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
params['before'] = int(end_dt.timestamp())
except ValueError:
@ -260,7 +414,12 @@ class StravaIntegrationView(viewsets.ViewSet):
activities = response.json()
essential_activities = [self.extract_essential_activity_info(act) for act in activities]
return Response(essential_activities, status=status.HTTP_200_OK)
return Response({
'activities': essential_activities,
'count': len(essential_activities),
'page': int(page),
'per_page': int(per_page)
}, status=status.HTTP_200_OK)
except requests.RequestException as e:
logger.error(f"Error fetching Strava activities: {str(e)}")

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>

File diff suppressed because it is too large Load diff

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