Refactor sunrise/sunset functionality and introduce caching

- Renamed `ExternalSunTimesThrottle` to `ExternalSunriseSunsetThrottle` for clarity.
- Added `SunriseSunsetAPI` view to handle sunrise and sunset data retrieval.
- Implemented caching for sunrise/sunset data using `get_or_fetch_cached`.
- Created new service functions for fetching sunrise/sunset data from the external API.
- Updated related tests to ensure proper functionality and caching behavior.
- Removed deprecated sun times handling from the `LocationViewSet` for cleaner code.
This commit is contained in:
Sean Morley
2026-07-08 20:15:09 -04:00
parent b11ff035ae
commit bf063b0632
24 changed files with 1983 additions and 1611 deletions

View File

@@ -2,8 +2,13 @@ from typing import Optional
from urllib.parse import quote
import requests
from django.conf import settings
from adventures.providers.base import ProviderResult
from adventures.services.external_cache import get_or_fetch_cached
WIKIPEDIA_SUMMARY_CACHE_PREFIX = 'wikipedia_summary_v1'
WIKIPEDIA_SUMMARY_CACHE_TIMEOUT = getattr(settings, 'WIKIPEDIA_SUMMARY_CACHE_TIMEOUT', 60 * 60 * 24 * 7)
def fetch_summary(query: str, language: str = "en") -> ProviderResult[Optional[str]]:
@@ -11,9 +16,27 @@ def fetch_summary(query: str, language: str = "en") -> ProviderResult[Optional[s
if not normalized_query:
return ProviderResult(error="Missing query")
candidates = [normalized_query]
if "," in normalized_query:
head = normalized_query.split(",")[0].strip()
normalized_language = (language or "en").strip().lower() or "en"
def fetch() -> Optional[str]:
return _fetch_summary_uncached(normalized_query, normalized_language)
cached = get_or_fetch_cached(
WIKIPEDIA_SUMMARY_CACHE_PREFIX,
normalized_language,
normalized_query,
fetch_fn=fetch,
timeout=WIKIPEDIA_SUMMARY_CACHE_TIMEOUT,
)
if cached:
return ProviderResult(data=cached)
return ProviderResult(data=None)
def _fetch_summary_uncached(query: str, language: str) -> Optional[str]:
candidates = [query]
if "," in query:
head = query.split(",")[0].strip()
if head and head not in candidates:
candidates.append(head)
@@ -35,8 +58,8 @@ def fetch_summary(query: str, language: str = "en") -> ProviderResult[Optional[s
extract = (data.get("extract") or "").strip()
if len(extract) >= 120:
return ProviderResult(data=extract)
return extract
except requests.exceptions.RequestException:
continue
return ProviderResult(data=None)
return None

View File

View File

@@ -0,0 +1,48 @@
import logging
from typing import TypedDict
import requests
from adventures.providers.base import ProviderResult
logger = logging.getLogger(__name__)
API_BASE_URL = 'https://api.sunrisesunset.io/json'
class SunriseSunsetData(TypedDict):
date: str
sunrise: str
sunset: str
def fetch_sunrise_sunset(latitude: float, longitude: float, date: str) -> ProviderResult[SunriseSunsetData]:
"""Fetch sunrise and sunset for a coordinate and calendar date (YYYY-MM-DD)."""
if latitude is None or longitude is None:
return ProviderResult(error='Missing coordinates')
if not date:
return ProviderResult(error='Missing date')
api_url = f'{API_BASE_URL}?lat={latitude}&lng={longitude}&date={date}'
try:
response = requests.get(api_url, timeout=10)
except requests.RequestException as exc:
logger.warning('Sunrise/sunset API request failed for %s: %s', date, exc)
return ProviderResult(error='Sunrise/sunset API request failed')
if response.status_code != 200:
logger.warning('Sunrise/sunset API returned status %s for %s', response.status_code, date)
return ProviderResult(error='Sunrise/sunset API returned an error')
data = response.json() or {}
results = data.get('results', {})
sunrise = results.get('sunrise')
sunset = results.get('sunset')
if not sunrise or not sunset:
return ProviderResult(error='Sunrise/sunset not available for this date')
return ProviderResult(data={
'date': date,
'sunrise': sunrise,
'sunset': sunset,
})

View File

@@ -0,0 +1,44 @@
import hashlib
from typing import Callable, Optional, TypeVar
from django.conf import settings
from django.core.cache import cache
T = TypeVar('T')
def build_cache_key(prefix: str, *parts: str) -> str:
normalized = ':'.join((part or '').strip().lower() for part in parts)
if len(normalized) > 200:
digest = hashlib.sha256(normalized.encode()).hexdigest()
return f'{prefix}:{digest}'
return f'{prefix}:{normalized}'
def get_cached(prefix: str, *parts: str) -> Optional[T]:
return cache.get(build_cache_key(prefix, *parts))
def set_cached(prefix: str, *parts: str, value: T, timeout: Optional[int] = None) -> T:
cache.set(
build_cache_key(prefix, *parts),
value,
timeout or getattr(settings, 'EXTERNAL_API_CACHE_TIMEOUT', 60 * 60 * 24),
)
return value
def get_or_fetch_cached(
prefix: str,
*parts: str,
fetch_fn: Callable[[], Optional[T]],
timeout: Optional[int] = None,
) -> Optional[T]:
cached = get_cached(prefix, *parts)
if cached is not None:
return cached
value = fetch_fn()
if value is not None:
set_cached(prefix, *parts, value=value, timeout=timeout)
return value

View File

@@ -1,39 +0,0 @@
import logging
from typing import List, Dict
import requests
logger = logging.getLogger(__name__)
def get_sun_times_for_visits(latitude: float, longitude: float, visits: List[Dict]) -> List[Dict]:
"""Return sunrise/sunset times for visits given lat/lon and visit date entries.
visits: list of dicts with keys 'start_date' and 'id'
"""
sun_times = []
for visit in visits:
date = visit.get('start_date')
if not (date and latitude and longitude):
continue
api_url = (
f'https://api.sunrisesunset.io/json?'
f'lat={latitude}&lng={longitude}&date={date}'
)
try:
response = requests.get(api_url, timeout=10)
if response.status_code == 200:
data = response.json()
results = data.get('results', {})
if results.get('sunrise') and results.get('sunset'):
sun_times.append({
'date': date,
'visit_id': visit.get('id'),
'sunrise': results.get('sunrise'),
'sunset': results.get('sunset'),
})
except requests.RequestException:
logger.debug('Sun times API request failed for date %s', date)
continue
return sun_times

View File

View File

@@ -0,0 +1,71 @@
import logging
from datetime import timezone as dt_timezone
from typing import Optional
from django.conf import settings
from django.utils.dateparse import parse_date, parse_datetime
from adventures.providers.sun.sunrisesunset import SunriseSunsetData, fetch_sunrise_sunset
from adventures.services.external_cache import get_or_fetch_cached
logger = logging.getLogger(__name__)
SUNRISE_SUNSET_CACHE_PREFIX = 'sunrise_sunset_v1'
SUNRISE_SUNSET_CACHE_TIMEOUT = getattr(settings, 'SUNRISE_SUNSET_CACHE_TIMEOUT', 60 * 60 * 24 * 30)
def normalize_sunrise_sunset_date(value: str) -> Optional[str]:
"""Normalize an ISO datetime or YYYY-MM-DD string to YYYY-MM-DD."""
if not value:
return None
value = value.strip()
if len(value) >= 10 and value[4:5] == '-' and value[7:8] == '-':
parsed_date = parse_date(value[:10])
if parsed_date:
return parsed_date.isoformat()
parsed_datetime = parse_datetime(value)
if parsed_datetime:
if parsed_datetime.tzinfo is not None:
parsed_datetime = parsed_datetime.astimezone(dt_timezone.utc)
return parsed_datetime.date().isoformat()
return None
def _cache_key_parts(latitude: float, longitude: float, date_value: str) -> tuple[str, str, str]:
return (
f'{latitude:.4f}',
f'{longitude:.4f}',
date_value,
)
def get_sunrise_sunset_for_date(
latitude: float,
longitude: float,
date_value: str,
) -> Optional[SunriseSunsetData]:
"""Return cached sunrise/sunset for a coordinate and calendar date."""
normalized_date = normalize_sunrise_sunset_date(date_value)
if not normalized_date:
return None
lat_part, lng_part, _ = _cache_key_parts(latitude, longitude, normalized_date)
def fetch() -> Optional[SunriseSunsetData]:
result = fetch_sunrise_sunset(latitude, longitude, normalized_date)
if result.error or not result.data:
logger.debug('Sunrise/sunset unavailable for %s: %s', normalized_date, result.error)
return None
return result.data
return get_or_fetch_cached(
SUNRISE_SUNSET_CACHE_PREFIX,
lat_part,
lng_part,
normalized_date,
fetch_fn=fetch,
timeout=SUNRISE_SUNSET_CACHE_TIMEOUT,
)

View File

@@ -3,11 +3,16 @@ import re
from typing import Dict, List, Optional
from difflib import SequenceMatcher
from django.conf import settings
from adventures.services.external_cache import get_or_fetch_cached
from adventures.services.wikipedia.search import WikipediaClient, is_valid_image_url
logger = logging.getLogger(__name__)
WIKIPEDIA_DESC_CACHE_PREFIX = 'wikipedia_desc_v1'
WIKIPEDIA_DESC_CACHE_TIMEOUT = getattr(settings, 'WIKIPEDIA_DESC_CACHE_TIMEOUT', 60 * 60 * 24 * 7)
class WikipediaDescriptionService:
LANGUAGE_PATTERN = re.compile(r"^[a-z0-9-]{2,12}$", re.IGNORECASE)
@@ -47,6 +52,22 @@ class WikipediaDescriptionService:
def get_description(self, term: str, lang: str) -> Optional[Dict]:
if not term:
return None
normalized_term = term.strip()
normalized_lang = self.get_language(lang)
def fetch() -> Optional[Dict]:
return self._fetch_description(normalized_term, normalized_lang)
return get_or_fetch_cached(
WIKIPEDIA_DESC_CACHE_PREFIX,
normalized_lang,
normalized_term,
fetch_fn=fetch,
timeout=WIKIPEDIA_DESC_CACHE_TIMEOUT,
)
def _fetch_description(self, term: str, lang: str) -> Optional[Dict]:
candidates = self.client.get_candidate_pages(term, lang, max_candidates=self.MAX_CANDIDATES)
for candidate in candidates:
try:

View File

@@ -0,0 +1,41 @@
from unittest.mock import patch
from django.test import SimpleTestCase, override_settings
from adventures.services.sunrise_sunset.times import (
get_sunrise_sunset_for_date,
normalize_sunrise_sunset_date,
)
class NormalizeSunriseSunsetDateTests(SimpleTestCase):
def test_accepts_iso_date(self):
self.assertEqual(normalize_sunrise_sunset_date('2024-06-15'), '2024-06-15')
def test_accepts_iso_datetime(self):
self.assertEqual(normalize_sunrise_sunset_date('2024-06-15T14:30:00Z'), '2024-06-15')
def test_rejects_invalid_value(self):
self.assertIsNone(normalize_sunrise_sunset_date('not-a-date'))
@override_settings(SUNRISE_SUNSET_CACHE_TIMEOUT=3600)
class GetSunriseSunsetForDateTests(SimpleTestCase):
@patch('adventures.services.sunrise_sunset.times.get_or_fetch_cached')
def test_delegates_to_cache_helper(self, mock_get_or_fetch_cached):
mock_get_or_fetch_cached.return_value = {
'date': '2024-06-15',
'sunrise': '5:30 AM',
'sunset': '8:15 PM',
}
result = get_sunrise_sunset_for_date(40.7128, -74.0060, '2024-06-15T00:00:00Z')
self.assertEqual(result['sunrise'], '5:30 AM')
mock_get_or_fetch_cached.assert_called_once()
args = mock_get_or_fetch_cached.call_args.args
self.assertEqual(args[0], 'sunrise_sunset_v1')
self.assertEqual(args[3], '2024-06-15')
def test_returns_none_for_invalid_date(self):
self.assertIsNone(get_sunrise_sunset_for_date(40.7128, -74.0060, 'invalid'))

View File

@@ -0,0 +1,22 @@
from unittest.mock import patch
from django.test import SimpleTestCase, override_settings
from adventures.services.wikipedia.description_service import WikipediaDescriptionService
@override_settings(WIKIPEDIA_DESC_CACHE_TIMEOUT=3600)
class WikipediaDescriptionCacheTests(SimpleTestCase):
@patch('adventures.services.wikipedia.description_service.get_or_fetch_cached')
def test_get_description_uses_cache(self, mock_get_or_fetch_cached):
mock_get_or_fetch_cached.return_value = {'extract': 'A sample description', 'title': 'Paris'}
service = WikipediaDescriptionService()
result = service.get_description('Paris', 'en')
self.assertEqual(result['extract'], 'A sample description')
mock_get_or_fetch_cached.assert_called_once()
args = mock_get_or_fetch_cached.call_args.args
self.assertEqual(args[0], 'wikipedia_desc_v1')
self.assertEqual(args[1], 'en')
self.assertEqual(args[2], 'Paris')

View File

@@ -29,5 +29,5 @@ class ExternalWikipediaThrottle(ConditionalUserRateThrottle):
scope = "external_wikipedia"
class ExternalSunTimesThrottle(ConditionalUserRateThrottle):
scope = "external_sun_times"
class ExternalSunriseSunsetThrottle(ConditionalUserRateThrottle):
scope = "external_sunrise_sunset"

View File

@@ -2,6 +2,7 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
from adventures.views import *
from adventures.views.places_api_view import PlacesAPI
from adventures.views.sunrise_sunset_view import SunriseSunsetAPI
router = DefaultRouter()
router.register(r'locations', LocationViewSet, basename='locations')
@@ -15,6 +16,7 @@ router.register(r'checklists', ChecklistViewSet, basename='checklists')
router.register(r'images', ContentImageViewSet, basename='images')
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
router.register(r'places', PlacesAPI, basename='places')
router.register(r'sunrise-sunset', SunriseSunsetAPI, basename='sunrise-sunset')
router.register(r'categories', CategoryViewSet, basename='categories')
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
router.register(r'search', GlobalSearchView, basename='search')

View File

@@ -0,0 +1,18 @@
from adventures.models import Location
def user_can_access_location(location: Location, user) -> bool:
if location.is_public:
return True
if user.is_authenticated and location.user == user:
return True
if user.is_authenticated:
for collection in location.collections.all():
if collection.user == user:
return True
if collection.shared_with.filter(uuid=user.uuid).exists():
return True
return False

View File

@@ -20,8 +20,7 @@ from adventures.serializers import (
MapPinSerializer,
)
from adventures.utils import pagination
from adventures.throttling import ExternalGeocodeThrottle, ExternalSunTimesThrottle
from adventures.utils.geo import point_to_lat_lon
from adventures.throttling import ExternalGeocodeThrottle
from adventures.services.geocoding.reverse import reverse_geocode as reverse_geocode_service
from adventures.services.share_image import (
build_share_image,
@@ -393,32 +392,20 @@ class LocationViewSet(viewsets.ModelViewSet):
serializer = CalendarLocationSerializer(queryset, many=True)
return Response(serializer.data)
@action(
detail=True,
methods=['get'],
url_path='additional-info',
throttle_classes=[ExternalSunTimesThrottle],
)
@action(detail=True, methods=['get'], url_path='additional-info')
def additional_info(self, request, pk=None):
"""Get adventure with additional sunrise/sunset information."""
"""Get adventure with extended details (kept for backwards compatibility)."""
adventure = self.get_object()
user = request.user
# Validate access permissions
if not self._has_adventure_access(adventure, user):
return Response(
{"error": "User does not have permission to access this adventure"},
status=status.HTTP_403_FORBIDDEN
)
# Get base adventure data
serializer = self.get_serializer(adventure)
response_data = serializer.data
# Add sunrise/sunset data
response_data['sun_times'] = self._get_sun_times(adventure, response_data.get('visits', []))
return Response(response_data)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def duplicate(self, request, pk=None):
@@ -812,16 +799,6 @@ class LocationViewSet(viewsets.ModelViewSet):
return False
def _get_sun_times(self, adventure, visits):
"""Get sunrise/sunset times for adventure visits via service."""
from adventures.services.sun.times import get_sun_times_for_visits
try:
latitude, longitude = point_to_lat_lon(adventure.coordinates)
return get_sun_times_for_visits(latitude, longitude, visits)
except Exception:
return []
def paginate_and_respond(self, queryset, request):
"""Paginate queryset and return response."""
paginator = self.pagination_class()

View File

@@ -0,0 +1,49 @@
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from adventures.models import Location
from adventures.services.sunrise_sunset.times import get_sunrise_sunset_for_date
from adventures.throttling import ExternalSunriseSunsetThrottle
from adventures.utils.geo import point_to_lat_lon
from adventures.utils.location_access import user_can_access_location
class SunriseSunsetAPI(viewsets.ViewSet):
permission_classes = [AllowAny]
@action(detail=False, methods=['get'], throttle_classes=[ExternalSunriseSunsetThrottle])
def lookup(self, request):
location_id = request.query_params.get('location_id', '').strip()
date_value = request.query_params.get('date', '').strip()
if not location_id:
return Response({'error': 'location_id parameter is required'}, status=400)
if not date_value:
return Response({'error': 'date parameter is required'}, status=400)
try:
location = Location.objects.get(pk=location_id)
except Location.DoesNotExist:
return Response({'error': 'Location not found'}, status=404)
if not user_can_access_location(location, request.user):
return Response(
{'error': 'User does not have permission to access this location'},
status=403,
)
if not location.coordinates:
return Response({'error': 'Location has no coordinates'}, status=400)
try:
latitude, longitude = point_to_lat_lon(location.coordinates)
except Exception:
return Response({'error': 'Location coordinates are invalid'}, status=400)
sunrise_sunset = get_sunrise_sunset_for_date(latitude, longitude, date_value)
if not sunrise_sunset:
return Response({'error': 'Sunrise/sunset not available for this date'}, status=404)
return Response(sunrise_sunset)

View File

@@ -333,9 +333,14 @@ RATE_LIMIT_RATES = {
'external_geocode': getenv('RATE_LIMIT_EXTERNAL_GEOCODE', '120/minute'),
'external_recommendations': getenv('RATE_LIMIT_EXTERNAL_RECOMMENDATIONS', '30/minute'),
'external_wikipedia': getenv('RATE_LIMIT_EXTERNAL_WIKIPEDIA', '60/minute'),
'external_sun_times': getenv('RATE_LIMIT_EXTERNAL_SUN_TIMES', '30/minute'),
'external_sunrise_sunset': getenv('RATE_LIMIT_EXTERNAL_SUNRISE_SUNSET', '30/minute'),
}
SUNRISE_SUNSET_CACHE_TIMEOUT = int(getenv('SUNRISE_SUNSET_CACHE_TIMEOUT', str(60 * 60 * 24 * 30)))
WIKIPEDIA_DESC_CACHE_TIMEOUT = int(getenv('WIKIPEDIA_DESC_CACHE_TIMEOUT', str(60 * 60 * 24 * 7)))
WIKIPEDIA_SUMMARY_CACHE_TIMEOUT = int(getenv('WIKIPEDIA_SUMMARY_CACHE_TIMEOUT', str(60 * 60 * 24 * 7)))
EXTERNAL_API_CACHE_TIMEOUT = int(getenv('EXTERNAL_API_CACHE_TIMEOUT', str(60 * 60 * 24)))
FORCE_SOCIALACCOUNT_LOGIN = getenv('FORCE_SOCIALACCOUNT_LOGIN', 'false').lower() == 'true' # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured.
if getenv('EMAIL_BACKEND', 'console') == 'console':

View File

@@ -30,7 +30,7 @@ Per-endpoint overrides (all optional):
| `RATE_LIMIT_EXTERNAL_GEOCODE` | `120/minute` | External geocoding |
| `RATE_LIMIT_EXTERNAL_RECOMMENDATIONS` | `30/minute` | Recommendations |
| `RATE_LIMIT_EXTERNAL_WIKIPEDIA` | `60/minute` | Wikipedia lookups |
| `RATE_LIMIT_EXTERNAL_SUN_TIMES` | `30/minute` | Sun times API |
| `RATE_LIMIT_EXTERNAL_SUNRISE_SUNSET` | `30/minute` | Sunrise/sunset API |
## Performance

View File

@@ -84,7 +84,7 @@ Related guides: [Social Auth](social_auth.md), [Disable Registration](disable_re
| `RATE_LIMIT_EXTERNAL_GEOCODE` | No | External geocoding limit. | `120/minute` |
| `RATE_LIMIT_EXTERNAL_RECOMMENDATIONS` | No | Recommendations API limit. | `30/minute` |
| `RATE_LIMIT_EXTERNAL_WIKIPEDIA` | No | Wikipedia lookup limit. | `60/minute` |
| `RATE_LIMIT_EXTERNAL_SUN_TIMES` | No | Sun times API limit. | `30/minute` |
| `RATE_LIMIT_EXTERNAL_SUNRISE_SUNSET` | No | Sunrise/sunset API limit. | `30/minute` |
See [Advanced Configuration](advanced_configuration.md) for usage notes.

View File

@@ -0,0 +1,27 @@
export type SunriseSunset = {
date: string;
sunrise: string;
sunset: string;
};
export function visitDateKey(startDate: string | null | undefined): string | null {
if (!startDate) return null;
return startDate.split('T')[0] ?? null;
}
export async function fetchSunriseSunset(
locationId: string,
date: string
): Promise<SunriseSunset | null> {
const params = new URLSearchParams({
location_id: locationId,
date
});
const response = await fetch(`/api/sunrise-sunset/lookup/?${params.toString()}`);
if (!response.ok) {
return null;
}
return (await response.json()) as SunriseSunset;
}

View File

@@ -100,15 +100,6 @@ export type Location = {
trails: Trail[];
};
export type AdditionalLocation = Location & {
sun_times: {
date: string;
visit_id: string;
sunrise: string;
sunset: string;
}[];
};
export type Country = {
id: number;
name: string;

View File

@@ -154,9 +154,11 @@
"distance": "Distance",
"share_collection": "Share this Collection!",
"copy_link": "Copy Link",
"sun_times": "Sun Times",
"sunrise": "Sunrise",
"sunset": "Sunset",
"show_sunrise_sunset": "Show sunrise & sunset",
"loading_sunrise_sunset": "Loading sunrise & sunset...",
"sunrise_sunset_load_error": "Could not load sunrise and sunset times",
"open_in_maps": "Open in Maps",
"fetch_image": "Fetch Image",
"wikipedia": "Wikipedia",

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { AdditionalLocation, Location, Collection } from '$lib/types';
import type { Location } from '$lib/types';
import { buildLocationShareMeta } from '$lib/shareMeta.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
const id = event.params as { id: string };
let request = await fetch(`${endpoint}/api/locations/${id.id}/additional-info/`, {
let request = await fetch(`${endpoint}/api/locations/${id.id}/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
@@ -21,7 +21,7 @@ export const load = (async (event) => {
shareMeta: null
};
} else {
let adventure = (await request.json()) as AdditionalLocation;
let adventure = (await request.json()) as Location;
return {
props: {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { AdditionalLocation } from '$lib/types';
import { onMount } from 'svelte';
import type { Location } from '$lib/types';
import { fetchSunriseSunset, visitDateKey, type SunriseSunset } from '$lib/sunriseSunset';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import Lost from '$lib/assets/undraw_lost.svg';
@@ -42,9 +42,10 @@
export let data: PageData;
let measurementSystem = data.user?.measurement_system || 'metric';
console.log(data);
let adventure: AdditionalLocation;
let adventure: Location | undefined;
let visitSunriseSunset: Record<string, SunriseSunset> = {};
let sunriseSunsetLoading: Record<string, boolean> = {};
let currentSlide = 0;
$: adventurePriceLabel = adventure
@@ -64,7 +65,7 @@
let notFound: boolean = false;
let isEditModalOpen: boolean = false;
let isSocialShareModalOpen: boolean = false;
let adventure_images: { image: string; adventure: AdditionalLocation | null }[] = [];
let adventure_images: { image: string; adventure: Location | null }[] = [];
let modalInitialIndex: number = 0;
let isImageModalOpen: boolean = false;
let mapBasemapType = normalizeBasemapType(data.user?.map_style);
@@ -81,52 +82,86 @@
: { type: 'FeatureCollection', features: [] };
$: hasImagePins = imagePinGeoJson.features.length > 0;
onMount(async () => {
if (data.props.adventure) {
adventure = data.props.adventure;
adventure.images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
// Sort visits by their start date (oldest first / chronological). Fall back to created_at if start_date is missing.
if (adventure.visits && adventure.visits.length > 1) {
adventure.visits.sort((a, b) => {
const aTs = DateTime.fromISO(a.start_date || a.created_at || '').toMillis() || 0;
const bTs = DateTime.fromISO(b.start_date || b.created_at || '').toMillis() || 0;
return aTs - bTs; // oldest first (chronological)
});
}
} else {
notFound = true;
async function loadSunriseSunsetForDate(date: string) {
if (!adventure?.id || sunriseSunsetLoading[date] || visitSunriseSunset[date]) {
return;
}
});
function hasActivityGeojson(adventure: AdditionalLocation) {
sunriseSunsetLoading = { ...sunriseSunsetLoading, [date]: true };
try {
const sunriseSunset = await fetchSunriseSunset(adventure.id, date);
if (sunriseSunset) {
visitSunriseSunset = { ...visitSunriseSunset, [date]: sunriseSunset };
} else {
addToast('error', $t('adventures.sunrise_sunset_load_error'));
}
} finally {
const { [date]: _removed, ...rest } = sunriseSunsetLoading;
sunriseSunsetLoading = rest;
}
}
function applyLocationPageData(adventureData: Location | null | undefined) {
if (!adventureData) {
notFound = true;
adventure = undefined;
visitSunriseSunset = {};
sunriseSunsetLoading = {};
currentSlide = 0;
return;
}
notFound = false;
adventure = adventureData;
adventure.images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
if (adventure.visits && adventure.visits.length > 1) {
adventure.visits.sort((a, b) => {
const aTs = DateTime.fromISO(a.start_date || a.created_at || '').toMillis() || 0;
const bTs = DateTime.fromISO(b.start_date || b.created_at || '').toMillis() || 0;
return aTs - bTs;
});
}
visitSunriseSunset = {};
sunriseSunsetLoading = {};
currentSlide = 0;
isImageModalOpen = false;
isEditModalOpen = false;
isSocialShareModalOpen = false;
}
$: applyLocationPageData(data.props.adventure);
function hasActivityGeojson(adventure: Location) {
return adventure.visits.some((visit) => visit.activities.some((activity) => activity.geojson));
}
function hasAttachmentGeojson(adventure: AdditionalLocation) {
function hasAttachmentGeojson(adventure: Location) {
return adventure.attachments.some((attachment) => attachment.geojson);
}
function hasTrailGeojson(adventure: AdditionalLocation) {
function hasTrailGeojson(adventure: Location) {
return adventure.trails?.some((trail) => trail.geojson) ?? false;
}
function getTotalActivities(adventure: AdditionalLocation) {
function getTotalActivities(adventure: Location) {
return adventure.visits.reduce(
(total, visit) => total + (visit.activities ? visit.activities.length : 0),
0
);
}
function getTotalDistance(adventure: AdditionalLocation) {
function getTotalDistance(adventure: Location) {
const totalMeters = adventure.visits.reduce(
(total, visit) =>
total +
@@ -141,7 +176,7 @@
return measurementSystem === 'imperial' ? totalKm * 0.621371 : totalKm;
}
function getTotalElevationGain(adventure: AdditionalLocation) {
function getTotalElevationGain(adventure: Location) {
const totalMeters = adventure.visits.reduce(
(total, visit) =>
total +
@@ -549,6 +584,7 @@
<h2 class="card-title text-2xl mb-6">🎯 {$t('adventures.visits')}</h2>
<div class="space-y-4">
{#each adventure.visits as visit, index}
{@const visitDate = visitDateKey(visit.start_date)}
<div class="flex gap-4">
<div class="flex flex-col items-center">
<div class="w-4 h-4 bg-primary rounded-full"></div>
@@ -559,71 +595,129 @@
<div class="flex-1 pb-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4">
{#if isVisitAllDay(visit.start_date, visit.end_date)}
<div class="flex items-center gap-2 mb-2">
<span class="badge badge-primary">All Day</span>
<span class="font-semibold">
{visit.start_date ? visit.start_date.split('T')[0] : ''} {visit.end_date
? visit.end_date.split('T')[0]
: ''}
</span>
</div>
{:else}
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="badge badge-primary">🕓 {$t('adventures.timed')}</span>
{#if visit.timezone}
<span class="badge badge-outline">{visit.timezone}</span>
{/if}
</div>
<div class="text-sm">
{#if visit.timezone}
<strong>{$t('adventures.start')}:</strong>
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}<br />
<strong>{$t('adventures.end')}:</strong>
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}
{:else}
<strong>Start:</strong>
{DateTime.fromISO(visit.start_date).toLocaleString(
DateTime.DATETIME_MED
)}<br />
<strong>End:</strong>
{DateTime.fromISO(visit.end_date).toLocaleString(
DateTime.DATETIME_MED
)}
{/if}
</div>
</div>
{/if}
{#if visit.notes}
<div class="mt-3 p-3 bg-base-200 rounded-lg">
<p class="text-sm italic">"{visit.notes}"</p>
</div>
{/if}
<div class="flex gap-2">
<div class="flex-1 min-w-0">
{#if isVisitAllDay(visit.start_date, visit.end_date)}
<div class="flex items-center gap-2 mb-2">
<span class="badge badge-primary">All Day</span>
<span class="font-semibold">
{visit.start_date ? visit.start_date.split('T')[0] : ''} {visit.end_date
? visit.end_date.split('T')[0]
: ''}
</span>
</div>
{:else}
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="badge badge-primary"
>🕓 {$t('adventures.timed')}</span
>
{#if visit.timezone}
<span class="badge badge-outline">{visit.timezone}</span>
{/if}
</div>
<div class="text-sm">
{#if visit.timezone}
<strong>{$t('adventures.start')}:</strong>
{DateTime.fromISO(visit.start_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}<br />
<strong>{$t('adventures.end')}:</strong>
{DateTime.fromISO(visit.end_date, { zone: 'utc' })
.setZone(visit.timezone)
.toLocaleString(DateTime.DATETIME_MED)}
{:else}
<strong>Start:</strong>
{DateTime.fromISO(visit.start_date).toLocaleString(
DateTime.DATETIME_MED
)}<br />
<strong>End:</strong>
{DateTime.fromISO(visit.end_date).toLocaleString(
DateTime.DATETIME_MED
)}
{/if}
</div>
</div>
{/if}
{#if visit.notes}
<div class="mt-3 p-3 bg-base-200 rounded-lg">
<p class="text-sm italic">"{visit.notes}"</p>
</div>
{/if}
<!-- Activities Section -->
{#if visit.activities && visit.activities.length > 0}
<div class="mt-4">
<h4 class="font-semibold mb-3 flex items-center gap-2">
🏃‍♂️ Activities ({visit.activities.length})
</h4>
<div class="space-y-3">
{#each visit.activities as activity}
<ActivityCard
{activity}
readOnly={true}
trails={adventure.trails}
{visit}
measurementSystem={data.user?.measurement_system || 'metric'}
/>
{/each}
</div>
<!-- Activities Section -->
{#if visit.activities && visit.activities.length > 0}
<div class="mt-4">
<h4 class="font-semibold mb-3 flex items-center gap-2">
🏃‍♂️ Activities ({visit.activities.length})
</h4>
<div class="space-y-3">
{#each visit.activities as activity}
<ActivityCard
{activity}
readOnly={true}
trails={adventure.trails}
{visit}
measurementSystem={data.user?.measurement_system ||
'metric'}
/>
{/each}
</div>
</div>
{/if}
</div>
{/if}
{#if visitDate && adventure.latitude && adventure.longitude}
<div class="shrink-0 self-start pt-0.5">
{#if visitSunriseSunset[visitDate]}
{@const sunriseSunset = visitSunriseSunset[visitDate]}
<div
class="tooltip tooltip-left"
data-tip="{$t(
'adventures.sunrise'
)}: {sunriseSunset.sunrise} • {$t(
'adventures.sunset'
)}: {sunriseSunset.sunset}"
>
<button
class="btn btn-circle btn-ghost btn-sm text-warning"
type="button"
aria-label="{$t(
'adventures.sunrise'
)}: {sunriseSunset.sunrise}, {$t(
'adventures.sunset'
)}: {sunriseSunset.sunset}"
>
<WeatherSunset class="w-5 h-5" />
</button>
</div>
{:else if sunriseSunsetLoading[visitDate]}
<button
class="btn btn-circle btn-ghost btn-sm"
type="button"
disabled
aria-label={$t('adventures.loading_sunrise_sunset')}
>
<span class="loading loading-spinner loading-xs"></span>
</button>
{:else}
<div
class="tooltip tooltip-left"
data-tip={$t('adventures.show_sunrise_sunset')}
>
<button
class="btn btn-circle btn-ghost btn-sm opacity-60 hover:opacity-100"
type="button"
aria-label={$t('adventures.show_sunrise_sunset')}
on:click={() => loadSunriseSunsetForDate(visitDate)}
>
<WeatherSunset class="w-5 h-5" />
</button>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
@@ -948,30 +1042,6 @@
</div>
{/if}
<!-- Sunrise/Sunset -->
{#if adventure.sun_times && adventure.sun_times.length > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
🌅 {$t('adventures.sun_times')}
<WeatherSunset class="w-5 h-5" />
</h3>
<div class="space-y-3">
{#each adventure.sun_times as sun_time}
<div class="border-l-4 border-warning pl-3">
<div class="font-semibold text-sm">
{new Date(sun_time.date).toLocaleDateString()}
</div>
<div class="text-xs opacity-70">
{$t('adventures.sunrise')}: {sun_time.sunrise}{$t('adventures.sunset')}: {sun_time.sunset}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Attachments -->
{#if adventure.attachments && adventure.attachments.length > 0}
<div class="card bg-base-200 shadow-xl">