Files
voyage/frontend/src/lib/components/locations/LocationVisits.svelte
alex f55b0ea230 fix: enforce dd/mm/yyyy, 24h time, and locale-aware location search
- Replace all 'en-US' and undefined locales with 'en-GB' in date
  formatting across 15+ frontend files (dateUtils.ts, cards, routes,
  Luxon calls) to consistently output day-first dates and 24h times
- Set hour12: false in all Intl.DateTimeFormat and toLocaleDateString
  calls that previously used 12h format
- Pass user's svelte-i18n locale as &lang= query param from
  LocationSearchMap and LocationQuickStart to the reverse-geocode API
- Extract lang param in reverse_geocode_view and forward to both
  search_osm and search_google
- Add Accept-Language header to Nominatim requests so searches return
  results in the user's language (e.g. Prague not Praha)
- Add languageCode field to Google Places API payload for same effect
2026-03-06 13:50:27 +00:00

1605 lines
52 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import type {
Collection,
StravaActivity,
Trail,
Activity,
Visit,
TransportationVisit
} from '$lib/types';
import TimezoneSelector from '../TimezoneSelector.svelte';
import { t } from 'svelte-i18n';
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
import { onMount } from 'svelte';
import { isAllDay, SPORT_TYPE_CHOICES } from '$lib';
import { createEventDispatcher } from 'svelte';
import { deserialize } from '$app/forms';
// Icons
import CalendarIcon from '~icons/mdi/calendar';
import ClockIcon from '~icons/mdi/clock';
import MapMarkerIcon from '~icons/mdi/map-marker';
import PlusIcon from '~icons/mdi/plus';
import EditIcon from '~icons/mdi/pencil';
import TrashIcon from '~icons/mdi/delete';
import AlertIcon from '~icons/mdi/alert';
import CheckIcon from '~icons/mdi/check';
import SettingsIcon from '~icons/mdi/cog';
import ArrowLeftIcon from '~icons/mdi/arrow-left';
import RunFastIcon from '~icons/mdi/run-fast';
import LoadingIcon from '~icons/mdi/loading';
import InfoIcon from '~icons/mdi/information';
import UploadIcon from '~icons/mdi/upload';
import FileIcon from '~icons/mdi/file';
import CloseIcon from '~icons/mdi/close';
import StravaActivityCard from '../StravaActivityCard.svelte';
import ActivityCard from '../cards/ActivityCard.svelte';
// Props
export let collection: Collection | null = null;
export let selectedStartTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
export let utcStartDate: string | null = null;
export let utcEndDate: string | null = null;
export let note: string | null = null;
export let visits: Visit[] | null = null;
export let objectId: string;
export let trails: Trail[] = [];
export let measurementSystem: 'metric' | 'imperial' = 'metric';
export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner
const dispatch = createEventDispatcher();
// Types
// Component state
let allDay: boolean = false;
let localStartDate: string = '';
let localEndDate: string = '';
let fullStartDate: string = '';
let fullEndDate: string = '';
let constrainDates: boolean = false;
let isEditing = false;
let visitIdEditing: string | null = null;
// Activity management state
let stravaEnabled: boolean = false;
let visitActivities: { [visitId: string]: StravaActivity[] } = {};
let loadingActivities: { [visitId: string]: boolean } = {};
let expandedVisits: { [visitId: string]: boolean } = {};
let uploadingActivity: { [visitId: string]: boolean } = {};
let showActivityUpload: { [visitId: string]: boolean } = {};
let pendingStravaImport: { [visitId: string]: StravaActivity | null } = {};
// Activity form state
let activityForm = {
name: '',
sport_type: 'Run',
distance: null as number | null,
moving_time: '',
elapsed_time: '',
elevation_gain: null as number | null,
elevation_loss: null as number | null,
start_date: '',
calories: null as number | null,
gpx_file: null as File | null,
trail: null as string | null,
elev_high: null as number | null,
elev_low: null as number | null,
rest_time: null as number | null,
average_speed: null as number | null,
max_speed: null as number | null,
average_cadence: null as number | null,
start_lat: null as number | null,
start_lng: null as number | null,
end_lat: null as number | null,
end_lng: null as number | null,
timezone: undefined as string | undefined
};
function getTypeConfig() {
return {
startLabel: 'Start Date',
endLabel: 'End Date',
icon: CalendarIcon,
color: 'primary'
};
}
// Reactive constraints
$: constraintStartDate = allDay
? fullStartDate && fullStartDate.includes('T')
? fullStartDate.split('T')[0]
: ''
: fullStartDate || '';
$: constraintEndDate = allDay
? fullEndDate && fullEndDate.includes('T')
? fullEndDate.split('T')[0]
: ''
: fullEndDate || '';
// Set the full date range for constraining purposes
$: if (collection && collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
}
// Update local display dates whenever timezone or UTC dates change
$: if (!isEditing) {
if (allDay) {
localStartDate = utcStartDate?.substring(0, 10) ?? '';
localEndDate = utcEndDate?.substring(0, 10) ?? '';
} else {
const start = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
const end = updateLocalDate({
utcDate: utcEndDate,
timezone: selectedStartTimezone
}).localDate;
localStartDate = start;
localEndDate = end;
}
}
// Helper functions
function formatDateInTimezone(utcDate: string, timezone: string): string {
try {
return new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(new Date(utcDate));
} catch {
return new Date(utcDate).toLocaleString('en-GB');
}
}
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
function parseDuration(duration: string): number {
const parts = duration.split(':').map(Number);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
return 0;
}
// Event handlers
function handleLocalDateChange() {
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
}
function handleAllDayToggle() {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: selectedStartTimezone
}).localDate;
}
async function addVisit(isAuto: boolean = false) {
// If editing an existing visit, patch instead of creating new
if (visitIdEditing) {
const response = await fetch(`/api/visits/${visitIdEditing}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
start_date: utcStartDate,
end_date: utcEndDate,
notes: note,
timezone: selectedStartTimezone
})
});
if (response.ok) {
const updatedVisit: Visit = await response.json();
visits = visits ? [...visits, updatedVisit] : [updatedVisit];
dispatch('visitAdded', updatedVisit);
visitIdEditing = null;
} else {
const errorText = await response.text();
}
} else {
// post to /api/visits for new visit
const response = await fetch('/api/visits/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
object_id: objectId,
start_date: utcStartDate,
end_date: utcEndDate,
notes: note,
timezone: selectedStartTimezone,
location: objectId
})
});
if (response.ok) {
const newVisit: Visit = await response.json();
visits = visits ? [...visits, newVisit] : [newVisit];
dispatch('visitAdded', newVisit);
} else {
const errorText = await response.text();
alert(`Failed to add visit: ${errorText}`);
}
}
// Reset form fields. If this call was an auto-generated visit, allow clearing even if initialVisitDate is set
if (!initialVisitDate || isAuto) {
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}
}
// Activity management functions
async function loadActivitiesForVisit(visit: Visit | TransportationVisit) {
if (!stravaEnabled) return;
loadingActivities[visit.id] = true;
loadingActivities = { ...loadingActivities };
try {
let startDate = new Date(visit.start_date);
let endDate = new Date(visit.end_date);
if (isAllDay(visit.start_date) && visit.end_date.includes('T00:00:00')) {
endDate = new Date(visit.end_date.replace('T00:00:00', 'T23:59:59'));
}
startDate.setHours(startDate.getHours() - 12);
endDate.setHours(endDate.getHours() + 12);
const bufferedStart = startDate.toISOString();
const bufferedEnd = endDate.toISOString();
const response = await fetch(
`/api/integrations/strava/activities/?start_date=${bufferedStart}&end_date=${bufferedEnd}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
);
if (response.ok) {
const apiRes = await response.json();
const filtered = apiRes.activities;
visitActivities[visit.id] = filtered;
visitActivities = { ...visitActivities };
} else {
console.error('Failed to load activities for visit:', await response.text());
visitActivities[visit.id] = [];
visitActivities = { ...visitActivities };
}
} catch (error) {
console.error('Error loading activities for visit:', error);
visitActivities[visit.id] = [];
visitActivities = { ...visitActivities };
} finally {
loadingActivities[visit.id] = false;
loadingActivities = { ...loadingActivities };
}
}
function toggleVisitActivities(visit: Visit | TransportationVisit) {
const isExpanded = expandedVisits[visit.id];
if (!isExpanded) {
expandedVisits[visit.id] = true;
expandedVisits = { ...expandedVisits };
if (!visitActivities[visit.id]) {
loadActivitiesForVisit(visit);
}
} else {
expandedVisits[visit.id] = false;
expandedVisits = { ...expandedVisits };
}
}
function showActivityUploadForm(visitId: string) {
showActivityUpload[visitId] = true;
showActivityUpload = { ...showActivityUpload };
// Reset form
activityForm = {
name: '',
sport_type: 'Run',
distance: null,
moving_time: '',
elapsed_time: '',
elevation_gain: null,
elevation_loss: null,
start_date: '',
calories: null,
gpx_file: null,
trail: null,
elev_high: null,
elev_low: null,
rest_time: null,
average_speed: null,
max_speed: null,
average_cadence: null,
start_lat: null,
start_lng: null,
end_lat: null,
end_lng: null,
timezone: undefined
};
}
function hideActivityUploadForm(visitId: string) {
showActivityUpload[visitId] = false;
showActivityUpload = { ...showActivityUpload };
// Clear pending import
delete pendingStravaImport[visitId];
pendingStravaImport = { ...pendingStravaImport };
}
function handleGpxFileChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
activityForm.gpx_file = target.files[0];
}
}
async function uploadActivity(visitId: string) {
if (!activityForm.name.trim()) {
alert($t('adventures.activity_name_required'));
return;
}
// If this is a Strava import, require GPX file
if (pendingStravaImport[visitId] && !activityForm.gpx_file) {
alert($t('strava.gpx_required'));
return;
}
uploadingActivity[visitId] = true;
uploadingActivity = { ...uploadingActivity };
try {
const formData = new FormData();
// Add basic activity data
formData.append('visit', visitId);
formData.append('name', activityForm.name);
if (activityForm.sport_type) formData.append('sport_type', activityForm.sport_type);
if (activityForm.distance) formData.append('distance', activityForm.distance.toString());
if (activityForm.moving_time) {
const seconds = parseDuration(activityForm.moving_time);
formData.append('moving_time', `PT${seconds}S`);
}
if (activityForm.elapsed_time) {
const seconds = parseDuration(activityForm.elapsed_time);
formData.append('elapsed_time', `PT${seconds}S`);
}
if (activityForm.elevation_gain)
formData.append('elevation_gain', activityForm.elevation_gain.toString());
if (activityForm.elevation_loss)
formData.append('elevation_loss', activityForm.elevation_loss.toString());
if (activityForm.start_date)
formData.append('start_date', formatUTCDate(activityForm.start_date));
if (activityForm.calories) formData.append('calories', activityForm.calories.toString());
if (activityForm.trail) formData.append('trail', activityForm.trail);
if (activityForm.elev_high) formData.append('elev_high', activityForm.elev_high.toString());
if (activityForm.elev_low) formData.append('elev_low', activityForm.elev_low.toString());
if (activityForm.rest_time) formData.append('rest_time', activityForm.rest_time.toString());
if (activityForm.average_speed)
formData.append('average_speed', activityForm.average_speed.toString());
if (activityForm.max_speed) formData.append('max_speed', activityForm.max_speed.toString());
if (activityForm.average_cadence)
formData.append('average_cadence', activityForm.average_cadence.toString());
if (activityForm.start_lat !== null)
formData.append('start_lat', activityForm.start_lat.toString());
if (activityForm.start_lng !== null)
formData.append('start_lng', activityForm.start_lng.toString());
if (activityForm.end_lat !== null)
formData.append('end_lat', activityForm.end_lat.toString());
if (activityForm.end_lng !== null)
formData.append('end_lng', activityForm.end_lng.toString());
if (activityForm.timezone) {
formData.append('timezone', activityForm.timezone);
}
// Add GPX file if provided
if (activityForm.gpx_file) {
formData.append('gpx_file', activityForm.gpx_file);
}
// Add external service ID if this is a Strava import
if (pendingStravaImport[visitId]) {
formData.append('external_service_id', pendingStravaImport[visitId].id.toString());
}
const response = await fetch('/locations?/activity', {
method: 'POST',
body: formData
});
if (response.ok) {
const newActivityResponse = deserialize(await response.text()) as { data: Activity };
const newActivity = newActivityResponse.data as Activity;
// Update the visit's activities array
if (visits) {
visits = visits.map((visit) => {
if (visit.id === visitId) {
return {
...visit,
activities: [...(visit.activities || []), newActivity]
};
}
return visit;
});
}
// Hide the upload form
hideActivityUploadForm(visitId);
} else {
const errorText = await response.text();
console.error('Failed to upload activity:', errorText);
}
} catch (error) {
console.error('Error uploading activity:', error);
} finally {
uploadingActivity[visitId] = false;
uploadingActivity = { ...uploadingActivity };
}
}
async function deleteActivity(visitId: string, activityId: string) {
if (!confirm($t('adventures.confirm_delete_activity'))) return;
try {
const response = await fetch(`/api/activities/${activityId}/`, {
method: 'DELETE'
});
if (response.ok) {
// Refetch the location data to get the updated visits with correct IDs
const locationResponse = await fetch(`/api/locations/${objectId}/`);
if (locationResponse.ok) {
const updatedLocation = await locationResponse.json();
visits = updatedLocation.visits;
} else {
console.error('Failed to refetch location data:', await locationResponse.text());
}
} else {
console.error('Failed to delete activity:', await response.text());
}
} catch (error) {
console.error('Error deleting activity:', error);
}
}
async function handleStravaActivityImport(event: CustomEvent<StravaActivity>, visitId: string) {
const stravaActivity = event.detail;
try {
// Store the pending import and show upload form
pendingStravaImport[visitId] = stravaActivity;
pendingStravaImport = { ...pendingStravaImport };
// Pre-fill the activity form with Strava data
activityForm = {
name: stravaActivity.name,
sport_type: stravaActivity.sport_type || stravaActivity.type,
distance: stravaActivity.distance || null, // Keep in meters
moving_time: stravaActivity.moving_time ? formatDuration(stravaActivity.moving_time) : '',
elapsed_time: stravaActivity.elapsed_time
? formatDuration(stravaActivity.elapsed_time)
: '',
elevation_gain: stravaActivity.total_elevation_gain || null,
elevation_loss: stravaActivity.estimated_elevation_loss || null,
start_date: stravaActivity.start_date ? stravaActivity.start_date.substring(0, 16) : '',
calories: stravaActivity.calories || null,
gpx_file: null,
trail: null,
elev_high: stravaActivity.elev_high || null,
elev_low: stravaActivity.elev_low || null,
rest_time: stravaActivity.rest_time || null,
average_speed: stravaActivity.average_speed || null,
max_speed: stravaActivity.max_speed || null,
average_cadence: stravaActivity.average_cadence || null,
start_lat: stravaActivity.start_latlng ? stravaActivity.start_latlng[0] : null,
start_lng: stravaActivity.start_latlng ? stravaActivity.start_latlng[1] : null,
end_lat: stravaActivity.end_latlng ? stravaActivity.end_latlng[0] : null,
end_lng: stravaActivity.end_latlng ? stravaActivity.end_latlng[1] : null,
timezone: stravaActivity.timezone || undefined
};
// Show the upload form
showActivityUpload[visitId] = true;
showActivityUpload = { ...showActivityUpload };
} catch (error) {
console.error('Error initiating Strava import:', error);
}
}
function editVisit(visit: Visit) {
isEditing = true;
visitIdEditing = visit.id;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
if ('start_timezone' in visit && typeof visit.start_timezone === 'string') {
selectedStartTimezone = visit.start_timezone;
if ('end_timezone' in visit && typeof visit.end_timezone === 'string') {
selectedStartTimezone = visit.end_timezone;
}
} else if (visit.timezone) {
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit && typeof visit.end_timezone === 'string'
? visit.end_timezone
: selectedStartTimezone
}).localDate;
}
// Remove the visit from the array temporarily for editing
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
// Clean up activities for this visit
delete visitActivities[visit.id];
delete expandedVisits[visit.id];
delete loadingActivities[visit.id];
delete showActivityUpload[visit.id];
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}
function removeVisit(visitId: string) {
if (visits) {
visits = visits.filter((v) => v.id !== visitId);
}
// Clean up activities for this visit
delete visitActivities[visitId];
delete expandedVisits[visitId];
delete loadingActivities[visitId];
delete showActivityUpload[visitId];
// make the DELETE request
fetch(`/api/visits/${visitId}/`, {
method: 'DELETE'
}).then((response) => {
if (!response.ok) {
console.error('Error deleting visit:', response.statusText);
} else {
// remove the visit from the local state
visits = visits?.filter((v) => v.id !== visitId) ?? null;
}
});
}
function handleBack() {
dispatch('back');
}
function handleClose() {
dispatch('close');
}
// Lifecycle
onMount(async () => {
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: selectedStartTimezone
}).localDate;
if (!selectedStartTimezone) {
selectedStartTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
if (!selectedStartTimezone) {
selectedStartTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Check if Strava is enabled by making a simple API call
try {
const response = await fetch('/api/integrations/strava/activities', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
stravaEnabled = response.ok;
} catch {
stravaEnabled = false;
}
// If initialVisitDate is provided and a visit on that date doesn't exist, create and upload a new all day visit on that date
if (initialVisitDate) {
const targetDate = initialVisitDate.split('T')[0]; // Ensure we have just YYYY-MM-DD
// Check if any visit already exists for this date
const visitExists = visits?.some((visit) => {
const visitStart = visit.start_date.split('T')[0];
const visitEnd = visit.end_date.split('T')[0];
return targetDate >= visitStart && targetDate <= visitEnd;
});
if (!visitExists) {
// Set up an all-day visit for this date
allDay = true;
localStartDate = targetDate;
localEndDate = targetDate;
// Convert to UTC dates
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay: true
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: selectedStartTimezone,
allDay: true
}).utcDate;
// Automatically save the visit
await addVisit(true);
// Clear the initialVisitDate after successful creation
initialVisitDate = null;
}
}
});
$: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid;
$: typeConfig = getTypeConfig();
</script>
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
<div class="max-w-full mx-auto space-y-6">
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-{typeConfig.color}/10 rounded-lg">
<svelte:component this={typeConfig.icon} class="w-5 h-5 text-{typeConfig.color}" />
</div>
<h2 class="text-xl font-bold">{$t('adventures.date_information')}</h2>
</div>
</div>
<!-- Settings Section -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
<div class="flex items-center gap-2 mb-4">
<SettingsIcon class="w-4 h-4 text-base-content/70" />
<h3 class="font-medium text-base-content/80">{$t('navbar.settings')}</h3>
</div>
<div class="space-y-4">
<!-- Timezone Selection -->
<div>
<label class="label-text text-sm font-medium" for="timezone-selector"
>{$t('adventures.timezone')}</label
>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
<!-- Toggles -->
<div class="flex flex-wrap gap-6">
<div class="flex items-center gap-3">
<ClockIcon class="w-4 h-4 text-base-content/70" />
<label class="label-text text-sm font-medium" for="all-day-toggle"
>{$t('adventures.all_day')}</label
>
<input
id="all-day-toggle"
type="checkbox"
class="toggle toggle-{typeConfig.color} toggle-sm"
bind:checked={allDay}
on:change={handleAllDayToggle}
/>
</div>
{#if collection?.start_date && collection?.end_date}
<div class="flex items-center gap-3">
<CalendarIcon class="w-4 h-4 text-base-content/70" />
<label class="label-text text-sm font-medium" for="constrain-dates"
>{$t('adventures.date_constrain')}</label
>
<input
id="constrain-dates"
type="checkbox"
class="toggle toggle-{typeConfig.color} toggle-sm"
bind:checked={constrainDates}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Date Selection Section -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
<h3 class="font-medium text-base-content/80 mb-4">{$t('adventures.date_selection')}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Start Date -->
<div>
<label class="label-text text-sm font-medium" for="start-date-input">
{typeConfig.startLabel}
</label>
{#if allDay}
<input
id="start-date-input"
type="date"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
id="start-date-input"
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
<!-- End Date -->
{#if localStartDate}
<div>
<label class="label-text text-sm font-medium" for="end-date-input">
{typeConfig.endLabel}
</label>
{#if allDay}
<input
id="end-date-input"
type="date"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
id="end-date-input"
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
{/if}
</div>
<!-- Notes (Location only) -->
<div class="mt-4">
<label class="label-text text-sm font-medium" for="visit-notes"
>{$t('adventures.notes')}</label
>
<textarea
id="visit-notes"
class="textarea textarea-bordered w-full mt-1"
rows="3"
placeholder={$t('adventures.notes_placeholder') + '...'}
bind:value={note}
></textarea>
</div>
<!-- Add Visit Button -->
<div class="flex justify-end mt-4">
<button
class="btn btn-{typeConfig.color} btn-sm gap-2"
type="button"
disabled={!localStartDate || !isDateValid}
on:click={() => addVisit(false)}
>
<PlusIcon class="w-4 h-4" />
{visitIdEditing ? $t('adventures.update_visit') : $t('adventures.add_visit')}
</button>
</div>
</div>
<!-- Validation Error -->
{#if !isDateValid}
<div class="alert alert-error mb-6">
<AlertIcon class="w-5 h-5" />
<span class="text-sm">{$t('adventures.invalid_date_range')}</span>
</div>
{/if}
<!-- Visits List (Location only) -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
<h3 class="font-medium text-base-content/80 mb-4">
{$t('adventures.visits')} ({visits?.length || 0})
</h3>
{#if !visits || visits.length === 0}
<div class="text-center py-8 text-base-content/60">
<CalendarIcon class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">{$t('adventures.no_visits')}</p>
<p class="text-xs text-base-content/40 mt-1">
{$t('adventures.no_visits_description')}
</p>
</div>
{:else}
<div class="space-y-3">
{#each visits as visit (visit.id)}
<div
class="bg-base-100 p-4 rounded-lg border border-base-300 hover:border-base-400 transition-colors"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline badge-sm"
>{$t('adventures.all_day')}</span
>
{:else}
<ClockIcon class="w-3 h-3 text-base-content/50" />
{/if}
{#if visit.timezone && !isAllDay(visit.start_date)}
<span class="badge badge-outline badge-sm">{visit.timezone}</span>
{/if}
<div class="text-sm font-medium truncate">
{#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 && visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)}
{formatDateInTimezone(visit.end_date, visit.timezone)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)}
{formatDateInTimezone(visit.end_date, visit.timezone)}
{:else}
{new Date(visit.start_date).toLocaleString('en-GB')}
{new Date(visit.end_date).toLocaleString('en-GB')}
{/if}
</div>
</div>
{#if visit.notes}
<p class="text-xs text-base-content/70 bg-base-200/50 p-2 rounded">
"{visit.notes}"
</p>
{/if}
{#if visit.activities && visit.activities.length > 0}
<div class="flex items-center gap-2 mt-2">
<RunFastIcon class="w-3 h-3 text-success" />
<span class="text-xs text-success font-medium">
{visit.activities.length}
{$t('adventures.saved_activities')}
</span>
</div>
{/if}
</div>
<!-- Visit Actions -->
<div class="flex gap-1 ml-4">
<!-- Activities Button (only show if Strava is enabled) -->
{#if stravaEnabled}
<button
class="btn btn-info btn-xs tooltip tooltip-top gap-1"
data-tip={$t('adventures.view_strava_activities')}
on:click={() => toggleVisitActivities(visit)}
>
<RunFastIcon class="w-3 h-3" />
{#if visitActivities[visit.id]}
({visitActivities[visit.id].length})
{/if}
</button>
{/if}
<!-- Upload Activity Button -->
<button
class="btn btn-success btn-xs tooltip tooltip-top gap-1"
data-tip={$t('adventures.add_activity')}
on:click={() => showActivityUploadForm(visit.id)}
>
<UploadIcon class="w-3 h-3" />
</button>
<button
class="btn btn-warning btn-xs tooltip tooltip-top"
data-tip={$t('adventures.edit_visit')}
on:click={() => editVisit(visit)}
>
<EditIcon class="w-3 h-3" />
</button>
<button
class="btn btn-error btn-xs tooltip tooltip-top"
data-tip={$t('adventures.remove_visit')}
on:click={() => removeVisit(visit.id)}
>
<TrashIcon class="w-3 h-3" />
</button>
</div>
</div>
<!-- Activity Upload Form -->
{#if showActivityUpload[visit.id]}
<div class="mt-4 pt-4 border-t border-base-300">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<UploadIcon class="w-4 h-4 text-success" />
<h4 class="font-medium text-sm">
{#if pendingStravaImport[visit.id]}
{$t('adventures.complete_strava_import')}
{:else}
{$t('adventures.add_new_activity')}
{/if}
</h4>
</div>
<button
class="btn btn-ghost btn-xs"
on:click={() => hideActivityUploadForm(visit.id)}
>
<CloseIcon class="w-3 h-3" />
</button>
</div>
{#if pendingStravaImport[visit.id]}
<div class="alert alert-info mb-4">
<div class="flex items-center gap-2">
<RunFastIcon class="w-4 h-4" />
<div class="text-sm">
<div class="font-medium">
{$t('adventures.strava_activity_ready')}
</div>
<div class="text-xs opacity-75">
{$t('adventures.gpx_file_downloaded')}
</div>
</div>
</div>
</div>
{/if}
<div class="bg-base-200/50 p-4 rounded-lg">
{#if pendingStravaImport[visit.id]}
<!-- Highlight GPX upload for Strava imports -->
<div class="mb-6 p-4 bg-warning/10 border-2 border-warning/30 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<FileIcon class="w-4 h-4 text-warning" />
<label
class="label-text font-medium text-warning"
for="gpx-file-{visit.id}"
>{$t('adventures.gpx_file_required')} *</label
>
</div>
<div class="flex gap-2">
<input
id="gpx-file-{visit.id}"
type="file"
accept=".gpx"
class="file-input file-input-bordered file-input-warning flex-1"
on:change={handleGpxFileChange}
/>
<button
type="button"
class="btn btn-warning btn-sm gap-1"
on:click={() => {
const stravaActivity = pendingStravaImport[visit.id];
if (stravaActivity && stravaActivity.export_gpx) {
window.open(stravaActivity.export_gpx, '_blank');
}
}}
>
<UploadIcon class="w-3 h-3" />
{$t('adventures.download_gpx')}
</button>
</div>
<div class="text-xs text-warning/80 mt-1">
{$t('adventures.upload_gpx_file')}
</div>
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Activity Name -->
<div class="md:col-span-2">
<label
class="label-text text-xs font-medium"
for="activity-name-{visit.id}"
>{$t('adventures.activity_name')} *</label
>
<input
id="activity-name-{visit.id}"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder={$t('adventures.activity_name_placeholder')}
bind:value={activityForm.name}
/>
</div>
<!-- Sport Type -->
<div>
<label
class="label-text text-xs font-medium"
for="sport-type-{visit.id}">{$t('adventures.sport_type')}</label
>
<select
id="sport-type-{visit.id}"
class="select select-bordered select-sm w-full mt-1"
bind:value={activityForm.sport_type}
disabled={!!pendingStravaImport[visit.id]}
>
{#each SPORT_TYPE_CHOICES as sportType}
<option value={sportType.key}
>{sportType.icon} {sportType.label}</option
>
{/each}
</select>
</div>
<!-- Distance -->
<div>
<label class="label-text text-xs font-medium" for="distance-{visit.id}"
>{$t('adventures.distance')} (km)</label
>
<input
id="distance-{visit.id}"
type="number"
step="0.01"
class="input input-bordered input-sm w-full mt-1"
placeholder="5.2"
bind:value={activityForm.distance}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Moving Time -->
<div>
<label
class="label-text text-xs font-medium"
for="moving-time-{visit.id}"
>{$t('adventures.moving_time')} (HH:MM:SS)</label
>
<input
id="moving-time-{visit.id}"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="0:25:30"
bind:value={activityForm.moving_time}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Elapsed Time -->
<div>
<label
class="label-text text-xs font-medium"
for="elapsed-time-{visit.id}"
>{$t('adventures.elapsed_time')} (HH:MM:SS)</label
>
<input
id="elapsed-time-{visit.id}"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="0:30:00"
bind:value={activityForm.elapsed_time}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Start Date -->
<div>
<label
class="label-text text-xs font-medium"
for="start-date-{visit.id}">{$t('adventures.start_date')}</label
>
<input
id="start-date-{visit.id}"
type="datetime-local"
class="input input-bordered input-sm w-full mt-1"
bind:value={activityForm.start_date}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Elevation Gain -->
{#if !activityForm.gpx_file}
<div>
<label
class="label-text text-xs font-medium"
for="elevation-gain-{visit.id}"
>{$t('adventures.elevation_gain')} (m)</label
>
<input
id="elevation-gain-{visit.id}"
type="number"
class="input input-bordered input-sm w-full mt-1"
placeholder="150"
bind:value={activityForm.elevation_gain}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
{/if}
<!-- Elevation Loss -->
{#if !activityForm.gpx_file}
<div>
<label
class="label-text text-xs font-medium"
for="elevation-loss-{visit.id}"
>{$t('adventures.elevation_loss')} (m)</label
>
<input
id="elevation-loss-{visit.id}"
type="number"
class="input input-bordered input-sm w-full mt-1"
placeholder="150"
bind:value={activityForm.elevation_loss}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
{/if}
<!-- Calories -->
<div>
<label class="label-text text-xs font-medium" for="calories-{visit.id}"
>{$t('adventures.calories')}</label
>
<input
id="calories-{visit.id}"
type="number"
class="input input-bordered input-sm w-full mt-1"
placeholder="300"
bind:value={activityForm.calories}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Elevation High -->
{#if !activityForm.gpx_file}
<div>
<label
class="label-text text-xs font-medium"
for="elevation-high-{visit.id}"
>{$t('adventures.elevation_high')} (m)</label
>
<input
id="elevation-high-{visit.id}"
type="number"
class="input input-bordered input-sm w-full mt-1"
placeholder="2000"
bind:value={activityForm.elev_high}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
{/if}
<!-- Elevation Low -->
{#if !activityForm.gpx_file}
<div>
<label
class="label-text text-xs font-medium"
for="elevation-low-{visit.id}"
>{$t('adventures.elevation_low')} (m)</label
>
<input
id="elevation-low-{visit.id}"
type="number"
class="input input-bordered input-sm w-full mt-1"
placeholder="1000"
bind:value={activityForm.elev_low}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
{/if}
<!-- Rest Time -->
<div>
<label class="label-text text-xs font-medium" for="rest-time-{visit.id}"
>{$t('adventures.rest_time')} (s)</label
>
<input
id="rest-time-{visit.id}"
type="number"
class="input input-bordered input-sm w-full mt-1"
placeholder="60"
bind:value={activityForm.rest_time}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Start Latitude -->
<div>
<label class="label-text text-xs font-medium" for="start-lat-{visit.id}"
>{$t('adventures.start_lat')} (°)</label
>
<input
id="start-lat-{visit.id}"
type="number"
step="any"
class="input input-bordered input-sm w-full mt-1"
placeholder="37.7749"
bind:value={activityForm.start_lat}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Start Longitude -->
<div>
<label class="label-text text-xs font-medium" for="start-lng-{visit.id}"
>{$t('adventures.start_lng')} (°)</label
>
<input
id="start-lng-{visit.id}"
type="number"
step="any"
class="input input-bordered input-sm w-full mt-1"
placeholder="-122.4194"
bind:value={activityForm.start_lng}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- End Latitude -->
<div>
<label class="label-text text-xs font-medium" for="end-lat-{visit.id}"
>{$t('adventures.end_lat')} (°)</label
>
<input
id="end-lat-{visit.id}"
type="number"
step="any"
class="input input-bordered input-sm w-full mt-1"
placeholder="37.7749"
bind:value={activityForm.end_lat}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- End Longitude -->
<div>
<label class="label-text text-xs font-medium" for="end-lng-{visit.id}"
>{$t('adventures.end_lng')} (°)</label
>
<input
id="end-lng-{visit.id}"
type="number"
step="any"
class="input input-bordered input-sm w-full mt-1"
placeholder="-122.4194"
bind:value={activityForm.end_lng}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Timezone -->
<div>
<label class="label-text text-xs font-medium" for="timezone-{visit.id}"
>{$t('adventures.timezone')}</label
>
<TimezoneSelector bind:selectedTimezone={activityForm.timezone} />
</div>
<!-- Average Speed -->
<div>
<label
class="label-text text-xs font-medium"
for="average-speed-{visit.id}"
>{$t('adventures.average_speed')} (m/s)</label
>
<input
id="average-speed-{visit.id}"
type="number"
step="any"
class="input input-bordered input-sm w-full mt-1"
placeholder="3.5"
bind:value={activityForm.average_speed}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Max Speed -->
<div>
<label class="label-text text-xs font-medium" for="max-speed-{visit.id}"
>{$t('adventures.max_speed')} (m/s)</label
>
<input
id="max-speed-{visit.id}"
type="number"
step="any"
class="input input-bordered input-sm w-full mt-1"
placeholder="5.0"
bind:value={activityForm.max_speed}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Average Cadence -->
<div>
<label
class="label-text text-xs font-medium"
for="average-cadence-{visit.id}"
>{$t('adventures.average_cadence')} (rpm)</label
>
<input
id="average-cadence-{visit.id}"
type="number"
step="any"
class="input input-bordered input-sm w-full mt-1"
placeholder="80"
bind:value={activityForm.average_cadence}
readonly={!!pendingStravaImport[visit.id]}
/>
</div>
<!-- Trail Selection -->
{#if trails && trails.length > 0}
<div class="md:col-span-2">
<label
class="label-text text-xs font-medium"
for="trail-select-{visit.id}">{$t('adventures.trail')}</label
>
<select
id="trail-select-{visit.id}"
class="select select-bordered select-sm w-full mt-1"
bind:value={activityForm.trail}
>
<option value="">Select a trail</option>
{#each trails as trail (trail.id)}
<option value={trail.id}>{trail.name}</option>
{/each}
</select>
</div>
{/if}
<!-- GPX File (for manual uploads) -->
{#if !pendingStravaImport[visit.id]}
<div class="md:col-span-2">
<label
class="label-text text-xs font-medium"
for="gpx-file-manual-{visit.id}">{$t('adventures.gpx_file')}</label
>
<input
id="gpx-file-manual-{visit.id}"
type="file"
accept=".gpx"
class="file-input file-input-bordered file-input-sm w-full mt-1"
on:change={handleGpxFileChange}
/>
</div>
{/if}
</div>
<div class="flex justify-end gap-2 mt-4">
<button
class="btn btn-ghost btn-sm"
on:click={() => hideActivityUploadForm(visit.id)}
disabled={uploadingActivity[visit.id]}
>
Cancel
</button>
<button
class="btn btn-success btn-sm gap-2"
on:click={() => uploadActivity(visit.id)}
disabled={uploadingActivity[visit.id] ||
!activityForm.name.trim() ||
(pendingStravaImport[visit.id] && !activityForm.gpx_file)}
>
{#if uploadingActivity[visit.id]}
<LoadingIcon class="w-3 h-3 animate-spin" />
{#if pendingStravaImport[visit.id]}
{$t('adventures.importing')}...
{:else}
{$t('adventures.uploading')}...
{/if}
{:else if pendingStravaImport[visit.id]}
<UploadIcon class="w-3 h-3" />
{$t('adventures.complete_import')}
{:else}
<UploadIcon class="w-3 h-3" />
{$t('adventures.upload_activity')}
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Saved Activities Section -->
{#if visit.activities && visit.activities.length > 0}
<div class="mt-4 pt-4 border-t border-base-300">
<div class="flex items-center gap-2 mb-3">
<RunFastIcon class="w-4 h-4 text-success" />
<h4 class="font-medium text-sm">
{$t('adventures.saved_activities')} ({visit.activities.length})
</h4>
</div>
<div class="space-y-2">
{#each visit.activities as activity (activity.id)}
<ActivityCard
{activity}
{trails}
{visit}
{measurementSystem}
on:delete={(event) =>
deleteActivity(event.detail.visitId, event.detail.activityId)}
/>
{/each}
</div>
</div>
{/if}
<!-- Strava Activities Section -->
{#if stravaEnabled && expandedVisits[visit.id]}
<div class="mt-4 pt-4 border-t border-base-300">
<div class="flex items-center gap-2 mb-3">
<RunFastIcon class="w-4 h-4 text-info" />
<h4 class="font-medium text-sm">
{$t('adventures.strava_activities_during_visit')}
</h4>
{#if loadingActivities[visit.id]}
<LoadingIcon class="w-4 h-4 animate-spin text-info" />
{/if}
</div>
{#if loadingActivities[visit.id]}
<div class="text-center py-4">
<div class="loading loading-spinner loading-sm"></div>
<p class="text-xs text-base-content/60 mt-2">
{$t('adventures.loading_activities')}...
</p>
</div>
{:else if visitActivities[visit.id] && visitActivities[visit.id].length > 0}
<div class="space-y-2">
{#each visitActivities[visit.id] as activity (activity.id)}
<div class="pl-4">
<StravaActivityCard
{activity}
on:import={(event) => handleStravaActivityImport(event, visit.id)}
{measurementSystem}
/>
</div>
{/each}
</div>
{:else}
<div class="text-center py-4 text-base-content/60">
<div class="text-2xl mb-2">🏃‍♂️</div>
<p class="text-xs">{$t('adventures.no_strava_activities')}</p>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<!-- if localStartDate and localEndDate are set, show a callout saying its not saved yet -->
{#if localStartDate || localEndDate}
<div class="alert alert-neutral">
<InfoIcon class="w-5 h-5" />
<div>
<div class="font-medium text-sm">{$t('adventures.dates_not_saved')}</div>
<div class="text-xs opacity-75">{$t('adventures.dates_not_saved_description')}</div>
</div>
</div>
{/if}
<div class="flex gap-3 justify-end pt-4">
<button class="btn btn-neutral-200 gap-2" on:click={handleBack}>
<ArrowLeftIcon class="w-5 h-5" />
{$t('adventures.back')}
</button>
<button class="btn btn-primary gap-2" on:click={handleClose}>
<CheckIcon class="w-5 h-5" />
{$t('adventures.done')}
</button>
</div>
</div>
</div>