From 5046bd49f7dd0b4e6c9ee25a53599ee181014aa9 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sun, 3 Aug 2025 13:41:57 -0400 Subject: [PATCH] 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. --- backend/server/adventures/admin.py | 5 +- ...alter_collectioninvite_options_activity.py | 55 + backend/server/adventures/models.py | 57 +- backend/server/adventures/permissions.py | 7 +- backend/server/adventures/serializers.py | 27 +- backend/server/adventures/urls.py | 1 + backend/server/adventures/views/__init__.py | 3 +- .../server/adventures/views/activity_view.py | 40 + .../server/integrations/views/strava_view.py | 193 ++- .../lib/components/NewLocationModal.svelte | 1 + .../lib/components/StravaActivityCard.svelte | 205 +++ .../locations/LocationVisits.svelte | 1202 +++++++++++++---- frontend/src/lib/types.ts | 85 ++ 13 files changed, 1626 insertions(+), 255 deletions(-) create mode 100644 backend/server/adventures/migrations/0058_alter_collectioninvite_options_activity.py create mode 100644 backend/server/adventures/views/activity_view.py create mode 100644 frontend/src/lib/components/StravaActivityCard.svelte diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index d033399..c9db330 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -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' diff --git a/backend/server/adventures/migrations/0058_alter_collectioninvite_options_activity.py b/backend/server/adventures/migrations/0058_alter_collectioninvite_options_activity.py new file mode 100644 index 0000000..4512925 --- /dev/null +++ b/backend/server/adventures/migrations/0058_alter_collectioninvite_options_activity.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.2 on 2025-08-03 16:30 + +import adventures.models +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0057_trail'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='collectioninvite', + options={'verbose_name': 'Collection Invite'}, + ), + migrations.CreateModel( + name='Activity', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('gpx_file', models.FileField(blank=True, null=True, upload_to=adventures.models.PathAndRename('activities/'), validators=[adventures.models.validate_file_extension])), + ('name', models.CharField(max_length=200)), + ('type', models.CharField(default='general', max_length=100)), + ('sport_type', models.CharField(blank=True, max_length=100, null=True)), + ('distance', models.FloatField(blank=True, null=True)), + ('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_gain', models.FloatField(blank=True, null=True)), + ('elevation_loss', models.FloatField(blank=True, null=True)), + ('elev_high', models.FloatField(blank=True, null=True)), + ('elev_low', models.FloatField(blank=True, null=True)), + ('start_date', models.DateTimeField(blank=True, null=True)), + ('start_date_local', models.DateTimeField(blank=True, null=True)), + ('timezone', models.CharField(blank=True, choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Sydney', 'Australia/Sydney'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zurich', 'Europe/Zurich'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis')], max_length=50, null=True)), + ('average_speed', models.FloatField(blank=True, null=True)), + ('max_speed', models.FloatField(blank=True, null=True)), + ('average_cadence', models.FloatField(blank=True, null=True)), + ('calories', models.FloatField(blank=True, null=True)), + ('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)), + ('external_service_id', models.CharField(blank=True, max_length=100, null=True)), + ('trail', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='adventures.trail')), + ('user', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('visit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='adventures.visit')), + ], + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index b21feb1..1a70a4b 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -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'})" \ No newline at end of file + 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" \ No newline at end of file diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index c5dd49d..11aec80 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -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) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 6254beb..7bf5faa 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -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): diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index e826552..71a117b 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -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 diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 82ae140..8f5e4ab 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -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 * \ No newline at end of file +from .trail_view import * +from .activity_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/activity_view.py b/backend/server/adventures/views/activity_view.py new file mode 100644 index 0000000..24acedb --- /dev/null +++ b/backend/server/adventures/views/activity_view.py @@ -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) \ No newline at end of file diff --git a/backend/server/integrations/views/strava_view.py b/backend/server/integrations/views/strava_view.py index 27b63c6..dfd28bc 100644 --- a/backend/server/integrations/views/strava_view.py +++ b/backend/server/integrations/views/strava_view.py @@ -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)}") diff --git a/frontend/src/lib/components/NewLocationModal.svelte b/frontend/src/lib/components/NewLocationModal.svelte index 60e2158..d3f0729 100644 --- a/frontend/src/lib/components/NewLocationModal.svelte +++ b/frontend/src/lib/components/NewLocationModal.svelte @@ -285,6 +285,7 @@ {#if steps[3].selected} { diff --git a/frontend/src/lib/components/StravaActivityCard.svelte b/frontend/src/lib/components/StravaActivityCard.svelte new file mode 100644 index 0000000..8bf84fd --- /dev/null +++ b/frontend/src/lib/components/StravaActivityCard.svelte @@ -0,0 +1,205 @@ + + +
+
+ +
+
+
{typeConfig.icon}
+
+

{activity.name}

+
+ {typeConfig.name} + + {formatDate(activity.start_date)} +
+
+
+ + +
+ + +
+
+
Distance
+
{activity.distance_km.toFixed(2)}
+
km ({activity.distance_miles.toFixed(2)} mi)
+
+
+
Time
+
{formatTime(activity.moving_time)}
+
Moving time
+
+
+
Avg Speed
+
{activity.average_speed_kmh.toFixed(1)}
+
km/h ({activity.average_speed_mph.toFixed(1)} mph)
+
+
+
Elevation
+
{activity.total_elevation_gain.toFixed(0)}
+
m gain
+
+
+ + +
+ {#if activity.average_cadence} +
+ Cadence: {activity.average_cadence.toFixed(1)} +
+ {/if} + {#if activity.calories} +
+ Calories: {activity.calories} +
+ {/if} + {#if activity.kudos_count > 0} +
+ Kudos: {activity.kudos_count} +
+ {/if} + {#if activity.achievement_count > 0} +
+ Achievements: {activity.achievement_count} +
+ {/if} + {#if activity.pr_count > 0} +
+ PRs: {activity.pr_count} +
+ {/if} +
+ + + {#if activity.pace_per_km_seconds} +
+
+ Pace: + {formatPace(activity.pace_per_km_seconds)}/km +
+
+ Max Speed: + {activity.max_speed_kmh.toFixed(1)} km/h +
+
+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/locations/LocationVisits.svelte b/frontend/src/lib/components/locations/LocationVisits.svelte index f82da7c..0ae39f7 100644 --- a/frontend/src/lib/components/locations/LocationVisits.svelte +++ b/frontend/src/lib/components/locations/LocationVisits.svelte @@ -1,5 +1,5 @@ -
-
- -
-
-
- -
-

{$t('adventures.date_information')}

-
- -
- - {#if isExpanded} - -
-
- -

Settings

+
+
+
+
+ +
+
+
+ +
+

{$t('adventures.date_information')}

+
-
- - {#if type === 'transportation'} -
+ +
+
+ +

Settings

+
+ +
+ + {#if type === 'transportation'} +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ {:else}
- +
-
- -
- -
-
-
- {:else} -
- -
- -
-
- {/if} + {/if} - -
-
- - - -
- - {#if collection?.start_date && collection?.end_date} + +
- - + +
- {/if} + + {#if collection?.start_date && collection?.end_date} +
+ + + +
+ {/if} +
-
- -
-

Date Selection

+ +
+

Date Selection

-
- -
- - {#if allDay} - - {:else} - - {/if} -
- - - {#if localStartDate} +
+
{#if allDay} {:else} {/if}
+ + + {#if localStartDate} +
+ + {#if allDay} + + {:else} + + {/if} +
+ {/if} +
+ + + {#if type === 'location'} +
+ + +
+ + +
+ +
{/if}
- - {#if type === 'location'} -
- - + + {#if !isDateValid} +
+ + Invalid date range - end date must be after start date
+ {/if} - -
- + + {#if type === 'location'} +
+

+ Visits ({visits?.length || 0}) +

+ + {#if !visits || visits.length === 0} +
+ +

No visits added yet

+

+ Create your first visit by selecting dates above +

+
+ {:else} +
+ {#each visits as visit (visit.id)} +
+
+
+
+ {#if isAllDay(visit.start_date)} + All Day + {:else} + + {/if} +
+ {#if isAllDay(visit.start_date)} + {visit.start_date && typeof visit.start_date === 'string' + ? visit.start_date.split('T')[0] + : ''} + – {visit.end_date && typeof visit.end_date === 'string' + ? visit.end_date.split('T')[0] + : ''} + {:else if 'start_timezone' in visit} + {formatDateInTimezone(visit.start_date, visit.start_timezone)} + – {formatDateInTimezone(visit.end_date, visit.end_timezone)} + {:else if visit.timezone} + {formatDateInTimezone(visit.start_date, visit.timezone)} + – {formatDateInTimezone(visit.end_date, visit.timezone)} + {:else} + {new Date(visit.start_date).toLocaleString()} + – {new Date(visit.end_date).toLocaleString()} + {/if} +
+
+ + {#if visit.notes} +

+ "{visit.notes}" +

+ {/if} + + {#if visit.activities && visit.activities.length > 0} +
+ + + {visit.activities.length} saved activities + +
+ {/if} +
+ + +
+ + {#if stravaEnabled} + + {/if} + + + + + + +
+
+ + + {#if showActivityUpload[visit.id]} +
+
+
+ +

Add New Activity

+
+ +
+ +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ +
+ + +
+
+
+ {/if} + + + {#if visit.activities && visit.activities.length > 0} +
+
+ +

+ Saved Activities ({visit.activities.length}) +

+
+ +
+ {#each visit.activities as activity (activity.id)} +
+
+
+
+ +
{activity.name}
+ {activity.type} +
+ +
+ {#if activity.distance} +
+ Distance: {activity.distance} km + {#if activity.moving_time} + Time: {activity.moving_time} + {/if} +
+ {/if} + + {#if activity.elevation_gain || activity.elevation_loss} +
+ {#if activity.elevation_gain} + ↗ {activity.elevation_gain}m + {/if} + {#if activity.elevation_loss} + ↘ {activity.elevation_loss}m + {/if} +
+ {/if} + + {#if activity.start_date} +
+ Started: {new Date(activity.start_date).toLocaleString()} +
+ {/if} + + {#if activity.gpx_file} + + {/if} +
+
+ + +
+
+ {/each} +
+
+ {/if} + + + {#if stravaEnabled && expandedVisits[visit.id]} +
+
+ +

Strava Activities During Visit

+ {#if loadingActivities[visit.id]} + + {/if} +
+ + {#if loadingActivities[visit.id]} +
+
+

Loading activities...

+
+ {:else if visitActivities[visit.id] && visitActivities[visit.id].length > 0} +
+ {#each visitActivities[visit.id] as activity (activity.id)} +
+ handleStravaActivityImport(event, visit.id)} + /> +
+ {/each} +
+ {:else} +
+
🏃‍♂️
+

No Strava activities found during this visit

+
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if}
{/if}
+
- - {#if !isDateValid} -
- - Invalid date range - end date must be after start date -
- {/if} - - - {#if type === 'location'} -
-

- Visits ({visits?.length || 0}) -

- - {#if !visits || visits.length === 0} -
- -

No visits added yet

-

- Create your first visit by selecting dates above -

-
- {:else} -
- {#each visits as visit (visit.id)} -
-
-
-
- {#if isAllDay(visit.start_date)} - All Day - {:else} - - {/if} -
- {#if isAllDay(visit.start_date)} - {visit.start_date && typeof visit.start_date === 'string' - ? visit.start_date.split('T')[0] - : ''} - – {visit.end_date && typeof visit.end_date === 'string' - ? visit.end_date.split('T')[0] - : ''} - {:else if 'start_timezone' in visit} - {formatDateInTimezone(visit.start_date, visit.start_timezone)} - – {formatDateInTimezone(visit.end_date, visit.end_timezone)} - {:else if visit.timezone} - {formatDateInTimezone(visit.start_date, visit.timezone)} - – {formatDateInTimezone(visit.end_date, visit.timezone)} - {:else} - {new Date(visit.start_date).toLocaleString()} - – {new Date(visit.end_date).toLocaleString()} - {/if} -
-
- - {#if visit.notes} -

- "{visit.notes}" -

- {/if} -
- - -
- - -
-
-
- {/each} -
- {/if} -
- {/if} - {/if}