1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 04:49:37 +02:00

Merge branch 'main' of github.com:ClumsyAdmin/AdventureLog

This commit is contained in:
ClumsyAdmin 2025-03-21 20:25:40 -04:00
commit 113d41ca30
31 changed files with 939 additions and 270 deletions

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.8 on 2025-03-17 21:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0024_alter_attachment_file'),
]
operations = [
migrations.AlterField(
model_name='visit',
name='end_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='visit',
name='start_date',
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -76,8 +76,8 @@ User = get_user_model()
class Visit(models.Model): class Visit(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
adventure = models.ForeignKey('Adventure', on_delete=models.CASCADE, related_name='visits') adventure = models.ForeignKey('Adventure', on_delete=models.CASCADE, related_name='visits')
start_date = models.DateField(null=True, blank=True) start_date = models.DateTimeField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True) end_date = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True, null=True) notes = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View file

@ -136,9 +136,11 @@ class AdventureSerializer(CustomModelSerializer):
def get_is_visited(self, obj): def get_is_visited(self, obj):
current_date = timezone.now().date() current_date = timezone.now().date()
for visit in obj.visits.all(): for visit in obj.visits.all():
if visit.start_date and visit.end_date and (visit.start_date <= current_date): start_date = visit.start_date.date() if isinstance(visit.start_date, timezone.datetime) else visit.start_date
end_date = visit.end_date.date() if isinstance(visit.end_date, timezone.datetime) else visit.end_date
if start_date and end_date and (start_date <= current_date):
return True return True
elif visit.start_date and not visit.end_date and (visit.start_date <= current_date): elif start_date and not end_date and (start_date <= current_date):
return True return True
return False return False

View file

@ -22,7 +22,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
order_by = self.request.query_params.get('order_by', 'name') order_by = self.request.query_params.get('order_by', 'name')
order_direction = self.request.query_params.get('order_direction', 'asc') order_direction = self.request.query_params.get('order_direction', 'asc')
valid_order_by = ['name', 'upated_at'] valid_order_by = ['name', 'upated_at', 'start_date']
if order_by not in valid_order_by: if order_by not in valid_order_by:
order_by = 'updated_at' order_by = 'updated_at'
@ -35,6 +35,12 @@ class CollectionViewSet(viewsets.ModelViewSet):
ordering = 'lower_name' ordering = 'lower_name'
if order_direction == 'desc': if order_direction == 'desc':
ordering = f'-{ordering}' ordering = f'-{ordering}'
elif order_by == 'start_date':
ordering = 'start_date'
if order_direction == 'asc':
ordering = 'start_date'
else:
ordering = '-start_date'
else: else:
order_by == 'updated_at' order_by == 'updated_at'
ordering = 'updated_at' ordering = 'updated_at'

View file

@ -3,7 +3,6 @@ from allauth.socialaccount.models import SocialAccount
class NoPasswordAuthBackend(ModelBackend): class NoPasswordAuthBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None, **kwargs):
print("NoPasswordAuthBackend")
# First, attempt normal authentication # First, attempt normal authentication
user = super().authenticate(request, username=username, password=password, **kwargs) user = super().authenticate(request, username=username, password=password, **kwargs)
if user is None: if user is None:

View file

@ -84,6 +84,16 @@ export default defineConfig({
}, },
], ],
}, },
{
text: "Usage",
collapsed: false,
items: [
{
text: "How to use AdventureLog",
link: "/docs/usage/usage",
},
],
},
{ {
text: "Configuration", text: "Configuration",
collapsed: false, collapsed: false,
@ -134,6 +144,10 @@ export default defineConfig({
text: "No Images Displaying", text: "No Images Displaying",
link: "/docs/troubleshooting/no_images", link: "/docs/troubleshooting/no_images",
}, },
{
text: "Login and Registration Unresponsive",
link: "/docs/troubleshooting/login_unresponsive",
},
{ {
text: "Failed to Start Nginx", text: "Failed to Start Nginx",
link: "/docs/troubleshooting/nginx_failed", link: "/docs/troubleshooting/nginx_failed",

View file

@ -27,4 +27,6 @@ AdventureLog is open-source software, licensed under the GPL-3.0 license. This m
## About the Maintainer ## About the Maintainer
AdventureLog is created and maintained by [Sean Morley](https://seanmorley.com), a Computer Science student at the University of Connecticut. Sean is passionate about open-source software and building modern tools that help people solve real-world problems. Hi, I'm [Sean Morley](https://seanmorley.com), the creator of AdventureLog. I'm a Computer Science student at the University of Connecticut, and I'm passionate about open-source software and building modern tools that help people solve real-world problems. I created AdventureLog to solve a problem: the lack of a modern, open-source, user-friendly travel companion. Many existing travel apps are either too complex, too expensive, or too closed-off to be useful for the average traveler. AdventureLog aims to be the opposite: simple, beautiful, and open to everyone.
I hope you enjoy using AdventureLog as much as I enjoy creating it! If you have any questions, feedback, or suggestions, feel free to reach out to me via the email address listed on my website. I'm always happy to hear from users and help in any way I can. Thank you for using AdventureLog, and happy travels! 🌍

View file

@ -0,0 +1,19 @@
# Troubleshooting: Login and Registration Unresponsive
When you encounter issues with the login and registration pages being unresponsive in AdventureLog, it can be due to various reasons. This guide will help you troubleshoot and resolve the unresponsive login and registration pages in AdventureLog.
1. Check to make sure the backend container is running and accessible.
- Check the backend container logs to see if there are any errors or issues blocking the contianer from running.
2. Check the connection between the frontend and backend containers.
- Attempt login with the browser console network tab open to see if there are any errors or issues with the connection between the frontend and backend containers. If there is a connection issue, the code will show an error like `Failed to load resource: net::ERR_CONNECTION_REFUSED`. If this is the case, check the `PUBLIC_SERVER_URL` in the frontend container and refer to the installation docs to ensure the correct URL is set.
- If the error is `403`, continue to the next step.
3. The error most likely is due to a CSRF security config issue in either the backend or frontend.
- Check that the `ORIGIN` variable in the frontend is set to the URL where the frontend is access and you are accessing the app from currently.
- Check that the `CSRF_TRUSTED_ORIGINS` variable in the backend is set to a comma separated list of the origins where you use your backend server and frontend. One of these values should match the `ORIGIN` variable in the frontend.
4. If you are still experiencing issues, please refer to the [AdventureLog Discord Server](https://discord.gg/wRbQ9Egr8C) for further assistance, providing as much detail as possible about the issue you are experiencing!

View file

@ -0,0 +1,29 @@
# How to use AdventureLog
Welcome to AdventureLog! This guide will help you get started with AdventureLog and provide you with an overview of the features available to you.
## Key Terms
#### Adventures
- **Adventure**: think of an adventure as a point on a map, a location you want to visit, or a place you want to explore. An adventure can be anything you want it to be, from a local park to a famous landmark.
- **Visit**: a visit is added to an adventure. It contains a date and notes about when the adventure was visited. If an adventure is visited multiple times, multiple visits can be added. If there are no visits on an adventure or the date of all visits is in the future, the adventure is considered planned. If the date of the visit is in the past, the adventure is considered completed.
- **Category**: a category is a way to group adventures together. For example, you could have a category for parks, a category for museums, and a category for restaurants.
- **Tag**: a tag is a way to add additional information to an adventure. For example, you could have a tag for the type of cuisine at a restaurant or the type of art at a museum. Multiple tags can be added to an adventure.
- **Image**: an image is a photo that is added to an adventure. Images can be added to an adventure to provide a visual representation of the location or to capture a memory of the visit. These can be uploded from your device or with a service like [Immich](/docs/configuration/immich_integration) if the integration is enabled.
- **Attachment**: an attachment is a file that is added to an adventure. Attachments can be added to an adventure to provide additional information, such as a map of the location or a brochure from the visit.
#### Collections
- **Collection**: a collection is a way to group adventures together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group adventures together. When a start and end date is added to a collection, it acts like a trip to group adventures together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a map showing the route taken between adventures.
- **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time.
- **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time.
- **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information.
- **Checklist**: a checklist is a collection exclusive feature that allows you to add a checklist to your trip. This can be used to create a list of things to do during your trip or for planning purposes like packing lists. Checklists can be assigned to a specific day of the trip to help organize the information.
#### World Travel
- **World Travel**: the world travel feature of AdventureLog allows you to track the countries, regions, and cities you have visited during your lifetime. You can add visits to countries, regions, and cities, and view statistics about your travels. The world travel feature is a fun way to visualize where you have been and where you want to go next.
- **Country**: a country is a geographical area that is recognized as an independent nation. You can add visits to countries to track where you have been.
- **Region**: a region is a geographical area that is part of a country. You can add visits to regions to track where you have been within a country.
- **City**: a city is a geographical area that is a populated urban center. You can add visits to cities to track where you have been within a region.

View file

@ -6,6 +6,20 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let collection: Collection | null = null; export let collection: Collection | null = null;
let fullStartDate: string = '';
let fullEndDate: string = '';
let fullStartDateOnly: string = '';
let fullEndDateOnly: string = '';
let allDay: boolean = true;
// Set full start and end dates from collection
if (collection && collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
fullStartDateOnly = collection.start_date;
fullEndDateOnly = collection.end_date;
}
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let images: { id: string; image: string; is_primary: boolean }[] = []; let images: { id: string; image: string; is_primary: boolean }[] = [];
@ -72,7 +86,7 @@
import ActivityComplete from './ActivityComplete.svelte'; import ActivityComplete from './ActivityComplete.svelte';
import CategoryDropdown from './CategoryDropdown.svelte'; import CategoryDropdown from './CategoryDropdown.svelte';
import { findFirstValue } from '$lib'; import { findFirstValue, isAllDay } from '$lib';
import MarkdownEditor from './MarkdownEditor.svelte'; import MarkdownEditor from './MarkdownEditor.svelte';
import ImmichSelect from './ImmichSelect.svelte'; import ImmichSelect from './ImmichSelect.svelte';
import Star from '~icons/mdi/star'; import Star from '~icons/mdi/star';
@ -379,7 +393,10 @@
let new_start_date: string = ''; let new_start_date: string = '';
let new_end_date: string = ''; let new_end_date: string = '';
let new_notes: string = ''; let new_notes: string = '';
// Function to add a new visit.
function addNewVisit() { function addNewVisit() {
// If an end date isnt provided, assume its the same as start.
if (new_start_date && !new_end_date) { if (new_start_date && !new_end_date) {
new_end_date = new_start_date; new_end_date = new_start_date;
} }
@ -391,15 +408,31 @@
addToast('error', $t('adventures.no_start_date')); addToast('error', $t('adventures.no_start_date'));
return; return;
} }
// Convert input to UTC if not already.
if (new_start_date && !new_start_date.includes('Z')) {
new_start_date = new Date(new_start_date).toISOString();
}
if (new_end_date && !new_end_date.includes('Z')) {
new_end_date = new Date(new_end_date).toISOString();
}
// If the visit is all day, force the times to midnight.
if (allDay) {
new_start_date = new_start_date.split('T')[0] + 'T00:00:00.000Z';
new_end_date = new_end_date.split('T')[0] + 'T00:00:00.000Z';
}
adventure.visits = [ adventure.visits = [
...adventure.visits, ...adventure.visits,
{ {
start_date: new_start_date, start_date: new_start_date,
end_date: new_end_date, end_date: new_end_date,
notes: new_notes, notes: new_notes,
id: '' id: '' // or generate an id as needed
} }
]; ];
// Clear the input fields.
new_start_date = ''; new_start_date = '';
new_end_date = ''; new_end_date = '';
new_notes = ''; new_notes = '';
@ -669,13 +702,23 @@
on:change={() => (constrainDates = !constrainDates)} on:change={() => (constrainDates = !constrainDates)}
/> />
{/if} {/if}
<span class="label-text">{$t('adventures.all_day')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
bind:checked={allDay}
/>
</label> </label>
<div class="flex gap-2 mb-1"> <div class="flex gap-2 mb-1">
{#if !constrainDates} {#if !allDay}
<input <input
type="date" type="datetime-local"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="Start Date" placeholder={$t('adventures.start_date')}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
bind:value={new_start_date} bind:value={new_start_date}
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@ -685,10 +728,12 @@
}} }}
/> />
<input <input
type="date" type="datetime-local"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder={$t('adventures.end_date')} placeholder={$t('adventures.end_date')}
bind:value={new_end_date} bind:value={new_end_date}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@ -701,8 +746,8 @@
type="date" type="date"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder={$t('adventures.start_date')} placeholder={$t('adventures.start_date')}
min={collection?.start_date} min={constrainDates ? fullStartDateOnly : ''}
max={collection?.end_date} max={constrainDates ? fullEndDateOnly : ''}
bind:value={new_start_date} bind:value={new_start_date}
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@ -716,8 +761,8 @@
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder={$t('adventures.end_date')} placeholder={$t('adventures.end_date')}
bind:value={new_end_date} bind:value={new_end_date}
min={collection?.start_date} min={constrainDates ? fullStartDateOnly : ''}
max={collection?.end_date} max={constrainDates ? fullEndDateOnly : ''}
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@ -741,6 +786,31 @@
}} }}
></textarea> ></textarea>
</div> </div>
{#if !allDay}
<div role="alert" class="alert shadow-lg bg-neutral mt-2 mb-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
{$t('lodging.current_timezone')}:
{(() => {
const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const [continent, city] = tz.split('/');
return `${continent} (${city.replace('_', ' ')})`;
})()}
</span>
</div>
{/if}
<div class="flex gap-2"> <div class="flex gap-2">
<button type="button" class="btn btn-neutral" on:click={addNewVisit} <button type="button" class="btn btn-neutral" on:click={addNewVisit}
@ -749,24 +819,86 @@
</div> </div>
{#if adventure.visits.length > 0} {#if adventure.visits.length > 0}
<h2 class=" font-bold text-xl mt-2">{$t('adventures.my_visits')}</h2> <h2 class="font-bold text-xl mt-2">{$t('adventures.my_visits')}</h2>
{#each adventure.visits as visit} {#each adventure.visits as visit}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex gap-2"> <div class="flex gap-2 items-center">
<p> <p>
{new Date(visit.start_date).toLocaleDateString(undefined, { {#if isAllDay(visit.start_date)}
timeZone: 'UTC' <!-- For all-day events, show just the date -->
})} {new Date(visit.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
{:else}
<!-- For timed events, show date and time -->
{new Date(visit.start_date).toLocaleDateString()} ({new Date(
visit.start_date
).toLocaleTimeString()})
{/if}
</p> </p>
{#if visit.end_date && visit.end_date !== visit.start_date} {#if visit.end_date && visit.end_date !== visit.start_date}
<p> <p>
{new Date(visit.end_date).toLocaleDateString(undefined, { {#if isAllDay(visit.end_date)}
timeZone: 'UTC' <!-- For all-day events, show just the date -->
})} {new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC'
})}
{:else}
<!-- For timed events, show date and time -->
{new Date(visit.end_date).toLocaleDateString()} ({new Date(
visit.end_date
).toLocaleTimeString()})
{/if}
</p> </p>
{/if} {/if}
<div> <div>
<button
type="button"
class="btn btn-sm btn-neutral"
on:click={() => {
// Determine if this is an all-day event
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
if (isAllDayEvent) {
// For all-day events, use date only
new_start_date = visit.start_date.split('T')[0];
new_end_date = visit.end_date.split('T')[0];
} else {
// For timed events, format properly for datetime-local input
const startDate = new Date(visit.start_date);
const endDate = new Date(visit.end_date);
// Format as yyyy-MM-ddThh:mm
new_start_date =
startDate.getFullYear() +
'-' +
String(startDate.getMonth() + 1).padStart(2, '0') +
'-' +
String(startDate.getDate()).padStart(2, '0') +
'T' +
String(startDate.getHours()).padStart(2, '0') +
':' +
String(startDate.getMinutes()).padStart(2, '0');
new_end_date =
endDate.getFullYear() +
'-' +
String(endDate.getMonth() + 1).padStart(2, '0') +
'-' +
String(endDate.getDate()).padStart(2, '0') +
'T' +
String(endDate.getHours()).padStart(2, '0') +
':' +
String(endDate.getMinutes()).padStart(2, '0');
}
new_notes = visit.notes;
adventure.visits = adventure.visits.filter((v) => v !== visit);
}}
>
{$t('lodging.edit')}
</button>
<button <button
type="button" type="button"
class="btn btn-sm btn-error" class="btn btn-sm btn-error"

View file

@ -189,10 +189,31 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Form Actions -->
{#if !collection.start_date && !collection.end_date}
<div class="mt-4">
<div role="alert" class="alert alert-neutral">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>{$t('adventures.collection_no_start_end_date')}</span>
</div>
</div>
{/if}
<div class="mt-4"> <div class="mt-4">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
{$t('adventures.save_next')} {$t('notes.save')}
</button> </button>
<button type="button" class="btn" on:click={close}> <button type="button" class="btn" on:click={close}>
{$t('about.close')} {$t('about.close')}

View file

@ -124,7 +124,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.dates')}:</span> <span class="font-medium text-sm">{$t('adventures.dates')}:</span>
<p> <p>
{new Date(lodging.check_in).toLocaleString('en-US', { {new Date(lodging.check_in).toLocaleString(undefined, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@ -132,7 +132,7 @@
minute: 'numeric' minute: 'numeric'
})} })}
- -
{new Date(lodging.check_out).toLocaleString('en-US', { {new Date(lodging.check_out).toLocaleString(undefined, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View file

@ -7,7 +7,15 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import DeleteWarning from './DeleteWarning.svelte'; import DeleteWarning from './DeleteWarning.svelte';
// import ArrowDownThick from '~icons/mdi/arrow-down-thick'; // import ArrowDownThick from '~icons/mdi/arrow-down-thick';
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
function getTransportationIcon(type: string) {
if (type in TRANSPORTATION_TYPES_ICONS) {
return TRANSPORTATION_TYPES_ICONS[type as keyof typeof TRANSPORTATION_TYPES_ICONS];
} else {
return '🚗';
}
}
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let transportation: Transportation; export let transportation: Transportation;
@ -106,7 +114,9 @@
<h2 class="card-title text-lg font-semibold truncate">{transportation.name}</h2> <h2 class="card-title text-lg font-semibold truncate">{transportation.name}</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="badge badge-secondary"> <div class="badge badge-secondary">
{$t(`transportation.modes.${transportation.type}`)} {$t(`transportation.modes.${transportation.type}`) +
' ' +
getTransportationIcon(transportation.type)}
</div> </div>
{#if transportation.type == 'plane' && transportation.flight_number} {#if transportation.type == 'plane' && transportation.flight_number}
<div class="badge badge-neutral-200">{transportation.flight_number}</div> <div class="badge badge-neutral-200">{transportation.flight_number}</div>
@ -128,7 +138,7 @@
{#if transportation.date} {#if transportation.date}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.start')}:</span> <span class="font-medium text-sm">{$t('adventures.start')}:</span>
<p>{new Date(transportation.date).toLocaleString(undefined, { timeZone: 'UTC' })}</p> <p>{new Date(transportation.date).toLocaleString()}</p>
</div> </div>
{/if} {/if}
</div> </div>
@ -146,7 +156,7 @@
{#if transportation.end_date} {#if transportation.end_date}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.end')}:</span> <span class="font-medium text-sm">{$t('adventures.end')}:</span>
<p>{new Date(transportation.end_date).toLocaleString(undefined, { timeZone: 'UTC' })}</p> <p>{new Date(transportation.end_date).toLocaleString()}</p>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -16,10 +16,15 @@
let constrainDates: boolean = false; let constrainDates: boolean = false;
// Format date as local datetime
// Convert an ISO date to a datetime-local value in local time.
function toLocalDatetime(value: string | null): string { function toLocalDatetime(value: string | null): string {
if (!value) return ''; if (!value) return '';
const date = new Date(value); const date = new Date(value);
return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm // Adjust the time by subtracting the timezone offset.
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
// Return format YYYY-MM-DDTHH:mm
return date.toISOString().slice(0, 16);
} }
let transportation: Transportation = { let transportation: Transportation = {
@ -185,6 +190,14 @@
return; return;
} }
// Convert local dates to UTC
if (transportation.date && !transportation.date.includes('Z')) {
transportation.date = new Date(transportation.date).toISOString();
}
if (transportation.end_date && !transportation.end_date.includes('Z')) {
transportation.end_date = new Date(transportation.end_date).toISOString();
}
if (transportation.type != 'plane') { if (transportation.type != 'plane') {
transportation.flight_number = ''; transportation.flight_number = '';
} }
@ -422,6 +435,29 @@
</div> </div>
</div> </div>
{/if} {/if}
<div role="alert" class="alert shadow-lg bg-neutral mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
{$t('lodging.current_timezone')}:
{(() => {
const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const [continent, city] = tz.split('/');
return `${continent} (${city.replace('_', ' ')})`;
})()}
</span>
</div>
</div> </div>
</div> </div>

View file

@ -70,34 +70,65 @@ export function groupAdventuresByDate(
// Initialize all days in the range // Initialize all days in the range
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = getLocalDateString(currentDate);
groupedAdventures[dateString] = []; groupedAdventures[dateString] = [];
} }
adventures.forEach((adventure) => { adventures.forEach((adventure) => {
adventure.visits.forEach((visit) => { adventure.visits.forEach((visit) => {
if (visit.start_date) { if (visit.start_date) {
const adventureDate = new Date(visit.start_date).toISOString().split('T')[0]; // Check if this is an all-day event (both start and end at midnight)
if (visit.end_date) { const isAllDayEvent =
const endDate = new Date(visit.end_date).toISOString().split('T')[0]; isAllDay(visit.start_date) && (visit.end_date ? isAllDay(visit.end_date) : false);
// Loop through all days and include adventure if it falls within the range // For all-day events, we need to handle dates differently
if (isAllDayEvent && visit.end_date) {
// Extract just the date parts without time
const startDateStr = visit.start_date.split('T')[0];
const endDateStr = visit.end_date.split('T')[0];
// Loop through all days in the range
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const currentDateStr = getLocalDateString(currentDate);
// Include the current day if it falls within the adventure date range // Include the current day if it falls within the adventure date range
if (dateString >= adventureDate && dateString <= endDate) { if (currentDateStr >= startDateStr && currentDateStr <= endDateStr) {
if (groupedAdventures[dateString]) { if (groupedAdventures[currentDateStr]) {
groupedAdventures[dateString].push(adventure); groupedAdventures[currentDateStr].push(adventure);
} }
} }
} }
} else if (groupedAdventures[adventureDate]) { } else {
// If there's no end date, add adventure to the start date only // Handle regular events with time components
groupedAdventures[adventureDate].push(adventure); const adventureStartDate = new Date(visit.start_date);
const adventureDateStr = getLocalDateString(adventureStartDate);
if (visit.end_date) {
const adventureEndDate = new Date(visit.end_date);
const endDateStr = getLocalDateString(adventureEndDate);
// Loop through all days and include adventure if it falls within the range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateString = getLocalDateString(currentDate);
// Include the current day if it falls within the adventure date range
if (dateString >= adventureDateStr && dateString <= endDateStr) {
if (groupedAdventures[dateString]) {
groupedAdventures[dateString].push(adventure);
}
}
}
} else {
// If there's no end date, add adventure to the start date only
if (groupedAdventures[adventureDateStr]) {
groupedAdventures[adventureDateStr].push(adventure);
}
}
} }
} }
}); });
@ -106,6 +137,20 @@ export function groupAdventuresByDate(
return groupedAdventures; return groupedAdventures;
} }
function getLocalDateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Helper to check if a given date string represents midnight (all-day)
// Improved isAllDay function to handle different ISO date formats
export function isAllDay(dateStr: string): boolean {
// Check for various midnight formats in UTC
return dateStr.endsWith('T00:00:00Z') || dateStr.endsWith('T00:00:00.000Z');
}
export function groupTransportationsByDate( export function groupTransportationsByDate(
transportations: Transportation[], transportations: Transportation[],
startDate: Date, startDate: Date,
@ -116,22 +161,22 @@ export function groupTransportationsByDate(
// Initialize all days in the range // Initialize all days in the range
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = getLocalDateString(currentDate);
groupedTransportations[dateString] = []; groupedTransportations[dateString] = [];
} }
transportations.forEach((transportation) => { transportations.forEach((transportation) => {
if (transportation.date) { if (transportation.date) {
const transportationDate = new Date(transportation.date).toISOString().split('T')[0]; const transportationDate = getLocalDateString(new Date(transportation.date));
if (transportation.end_date) { if (transportation.end_date) {
const endDate = new Date(transportation.end_date).toISOString().split('T')[0]; const endDate = new Date(transportation.end_date).toISOString().split('T')[0];
// Loop through all days and include transportation if it falls within the range // Loop through all days and include transportation if it falls within the range
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = getLocalDateString(currentDate);
// Include the current day if it falls within the transportation date range // Include the current day if it falls within the transportation date range
if (dateString >= transportationDate && dateString <= endDate) { if (dateString >= transportationDate && dateString <= endDate) {
@ -157,35 +202,32 @@ export function groupLodgingByDate(
): Record<string, Lodging[]> { ): Record<string, Lodging[]> {
const groupedTransportations: Record<string, Lodging[]> = {}; const groupedTransportations: Record<string, Lodging[]> = {};
// Initialize all days in the range // Initialize all days in the range using local dates
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = getLocalDateString(currentDate);
groupedTransportations[dateString] = []; groupedTransportations[dateString] = [];
} }
transportations.forEach((transportation) => { transportations.forEach((transportation) => {
if (transportation.check_in) { if (transportation.check_in) {
const transportationDate = new Date(transportation.check_in).toISOString().split('T')[0]; // Use local date string conversion
const transportationDate = getLocalDateString(new Date(transportation.check_in));
if (transportation.check_out) { if (transportation.check_out) {
const endDate = new Date(transportation.check_out).toISOString().split('T')[0]; const endDate = getLocalDateString(new Date(transportation.check_out));
// Loop through all days and include transportation if it falls within the range // Loop through all days and include transportation if it falls within the transportation date range
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = getLocalDateString(currentDate);
// Include the current day if it falls within the transportation date range
if (dateString >= transportationDate && dateString <= endDate) { if (dateString >= transportationDate && dateString <= endDate) {
if (groupedTransportations[dateString]) { groupedTransportations[dateString].push(transportation);
groupedTransportations[dateString].push(transportation);
}
} }
} }
} else if (groupedTransportations[transportationDate]) { } else if (groupedTransportations[transportationDate]) {
// If there's no end date, add transportation to the start date only
groupedTransportations[transportationDate].push(transportation); groupedTransportations[transportationDate].push(transportation);
} }
} }
@ -201,19 +243,18 @@ export function groupNotesByDate(
): Record<string, Note[]> { ): Record<string, Note[]> {
const groupedNotes: Record<string, Note[]> = {}; const groupedNotes: Record<string, Note[]> = {};
// Initialize all days in the range // Initialize all days in the range using local dates
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = getLocalDateString(currentDate);
groupedNotes[dateString] = []; groupedNotes[dateString] = [];
} }
notes.forEach((note) => { notes.forEach((note) => {
if (note.date) { if (note.date) {
const noteDate = new Date(note.date).toISOString().split('T')[0]; // Use the date string as is since it's already in "YYYY-MM-DD" format.
const noteDate = note.date;
// Add note to the appropriate date group if it exists
if (groupedNotes[noteDate]) { if (groupedNotes[noteDate]) {
groupedNotes[noteDate].push(note); groupedNotes[noteDate].push(note);
} }
@ -230,19 +271,18 @@ export function groupChecklistsByDate(
): Record<string, Checklist[]> { ): Record<string, Checklist[]> {
const groupedChecklists: Record<string, Checklist[]> = {}; const groupedChecklists: Record<string, Checklist[]> = {};
// Initialize all days in the range // Initialize all days in the range using local dates
for (let i = 0; i < numberOfDays; i++) { for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate); const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + i); currentDate.setDate(startDate.getDate() + i);
const dateString = currentDate.toISOString().split('T')[0]; const dateString = getLocalDateString(currentDate);
groupedChecklists[dateString] = []; groupedChecklists[dateString] = [];
} }
checklists.forEach((checklist) => { checklists.forEach((checklist) => {
if (checklist.date) { if (checklist.date) {
const checklistDate = new Date(checklist.date).toISOString().split('T')[0]; // Use the date string as is since it's already in "YYYY-MM-DD" format.
const checklistDate = checklist.date;
// Add checklist to the appropriate date group if it exists
if (groupedChecklists[checklistDate]) { if (groupedChecklists[checklistDate]) {
groupedChecklists[checklistDate].push(checklist); groupedChecklists[checklistDate].push(checklist);
} }
@ -338,6 +378,17 @@ export let LODGING_TYPES_ICONS = {
other: '❓' other: '❓'
}; };
export let TRANSPORTATION_TYPES_ICONS = {
car: '🚗',
plane: '✈️',
train: '🚆',
bus: '🚌',
boat: '⛵',
bike: '🚲',
walking: '🚶',
other: '❓'
};
export function getAdventureTypeLabel(type: string) { export function getAdventureTypeLabel(type: string) {
// return the emoji ADVENTURE_TYPE_ICONS label for the given type if not found return ? emoji // return the emoji ADVENTURE_TYPE_ICONS label for the given type if not found return ? emoji
if (type in ADVENTURE_TYPE_ICONS) { if (type in ADVENTURE_TYPE_ICONS) {

View file

@ -247,7 +247,12 @@
"price": "Preis", "price": "Preis",
"reservation_number": "Reservierungsnummer", "reservation_number": "Reservierungsnummer",
"welcome_map_info": "Frei zugängliche Abenteuer auf diesem Server", "welcome_map_info": "Frei zugängliche Abenteuer auf diesem Server",
"open_in_maps": "In Karten öffnen" "open_in_maps": "In Karten öffnen",
"all_day": "Den ganzen Tag",
"collection_no_start_end_date": "Durch das Hinzufügen eines Start- und Enddatums zur Sammlung werden Reiseroutenplanungsfunktionen auf der Sammlungsseite freigegeben.",
"date_itinerary": "Datumstrecke",
"no_ordered_items": "Fügen Sie der Sammlung Elemente mit Daten hinzu, um sie hier zu sehen.",
"ordered_itinerary": "Reiseroute bestellt"
}, },
"home": { "home": {
"desc_1": "Entdecken, planen und erkunden Sie mühelos", "desc_1": "Entdecken, planen und erkunden Sie mühelos",

View file

@ -131,6 +131,7 @@
"search_for_location": "Search for a location", "search_for_location": "Search for a location",
"clear_map": "Clear map", "clear_map": "Clear map",
"search_results": "Searh results", "search_results": "Searh results",
"collection_no_start_end_date": "Adding a start and end date to the collection will unlock itinerary planning features in the collection page.",
"no_results": "No results found", "no_results": "No results found",
"wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.", "wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.",
"attachments": "Attachments", "attachments": "Attachments",
@ -250,6 +251,10 @@
"show_map": "Show Map", "show_map": "Show Map",
"emoji_picker": "Emoji Picker", "emoji_picker": "Emoji Picker",
"download_calendar": "Download Calendar", "download_calendar": "Download Calendar",
"all_day": "All Day",
"ordered_itinerary": "Ordered Itinerary",
"date_itinerary": "Date Itinerary",
"no_ordered_items": "Add items with dates to the collection to see them here.",
"date_information": "Date Information", "date_information": "Date Information",
"flight_information": "Flight Information", "flight_information": "Flight Information",
"out_of_range": "Not in itinerary date range", "out_of_range": "Not in itinerary date range",

View file

@ -295,7 +295,12 @@
"region": "Región", "region": "Región",
"reservation_number": "Número de reserva", "reservation_number": "Número de reserva",
"welcome_map_info": "Aventuras públicas en este servidor", "welcome_map_info": "Aventuras públicas en este servidor",
"open_in_maps": "Abrir en mapas" "open_in_maps": "Abrir en mapas",
"all_day": "Todo el día",
"collection_no_start_end_date": "Agregar una fecha de inicio y finalización a la colección desbloqueará las funciones de planificación del itinerario en la página de colección.",
"date_itinerary": "Itinerario de fecha",
"no_ordered_items": "Agregue elementos con fechas a la colección para verlos aquí.",
"ordered_itinerary": "Itinerario ordenado"
}, },
"worldtravel": { "worldtravel": {
"all": "Todo", "all": "Todo",

View file

@ -247,7 +247,12 @@
"region": "Région", "region": "Région",
"reservation_number": "Numéro de réservation", "reservation_number": "Numéro de réservation",
"welcome_map_info": "Aventures publiques sur ce serveur", "welcome_map_info": "Aventures publiques sur ce serveur",
"open_in_maps": "Ouvert dans les cartes" "open_in_maps": "Ouvert dans les cartes",
"all_day": "Toute la journée",
"collection_no_start_end_date": "L'ajout d'une date de début et de fin à la collection débloquera les fonctionnalités de planification de l'itinéraire dans la page de collection.",
"date_itinerary": "Itinéraire de date",
"no_ordered_items": "Ajoutez des articles avec des dates à la collection pour les voir ici.",
"ordered_itinerary": "Itinéraire ordonné"
}, },
"home": { "home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité", "desc_1": "Découvrez, planifiez et explorez en toute simplicité",

View file

@ -247,7 +247,12 @@
"region": "Regione", "region": "Regione",
"welcome_map_info": "Avventure pubbliche su questo server", "welcome_map_info": "Avventure pubbliche su questo server",
"reservation_number": "Numero di prenotazione", "reservation_number": "Numero di prenotazione",
"open_in_maps": "Aperto in mappe" "open_in_maps": "Aperto in mappe",
"all_day": "Tutto il giorno",
"collection_no_start_end_date": "L'aggiunta di una data di inizio e fine alla raccolta sbloccherà le funzionalità di pianificazione dell'itinerario nella pagina di raccolta.",
"date_itinerary": "Itinerario della data",
"no_ordered_items": "Aggiungi articoli con date alla collezione per vederli qui.",
"ordered_itinerary": "Itinerario ordinato"
}, },
"home": { "home": {
"desc_1": "Scopri, pianifica ed esplora con facilità", "desc_1": "Scopri, pianifica ed esplora con facilità",

View file

@ -247,7 +247,12 @@
"region": "지역", "region": "지역",
"reservation_number": "예약 번호", "reservation_number": "예약 번호",
"welcome_map_info": "이 서버의 공개 모험", "welcome_map_info": "이 서버의 공개 모험",
"open_in_maps": "지도에서 열립니다" "open_in_maps": "지도에서 열립니다",
"all_day": "하루 종일",
"collection_no_start_end_date": "컬렉션에 시작 및 종료 날짜를 추가하면 컬렉션 페이지에서 여정 계획 기능이 잠금 해제됩니다.",
"date_itinerary": "날짜 일정",
"no_ordered_items": "컬렉션에 날짜가있는 항목을 추가하여 여기에서 확인하십시오.",
"ordered_itinerary": "주문한 여정"
}, },
"auth": { "auth": {
"both_passwords_required": "두 암호 모두 필요합니다", "both_passwords_required": "두 암호 모두 필요합니다",

View file

@ -247,7 +247,12 @@
"lodging_information": "Informatie overliggen", "lodging_information": "Informatie overliggen",
"price": "Prijs", "price": "Prijs",
"region": "Regio", "region": "Regio",
"open_in_maps": "Open in kaarten" "open_in_maps": "Open in kaarten",
"all_day": "De hele dag",
"collection_no_start_end_date": "Als u een start- en einddatum aan de collectie toevoegt, ontgrendelt u de functies van de planning van de route ontgrendelen in de verzamelpagina.",
"date_itinerary": "Datumroute",
"no_ordered_items": "Voeg items toe met datums aan de collectie om ze hier te zien.",
"ordered_itinerary": "Besteld reisschema"
}, },
"home": { "home": {
"desc_1": "Ontdek, plan en verken met gemak", "desc_1": "Ontdek, plan en verken met gemak",

View file

@ -295,7 +295,12 @@
"region": "Region", "region": "Region",
"reservation_number": "Numer rezerwacji", "reservation_number": "Numer rezerwacji",
"welcome_map_info": "Publiczne przygody na tym serwerze", "welcome_map_info": "Publiczne przygody na tym serwerze",
"open_in_maps": "Otwarte w mapach" "open_in_maps": "Otwarte w mapach",
"all_day": "Cały dzień",
"collection_no_start_end_date": "Dodanie daty rozpoczęcia i końca do kolekcji odblokuje funkcje planowania planu podróży na stronie kolekcji.",
"date_itinerary": "Trasa daty",
"no_ordered_items": "Dodaj przedmioty z datami do kolekcji, aby je zobaczyć tutaj.",
"ordered_itinerary": "Zamówiono trasę"
}, },
"worldtravel": { "worldtravel": {
"country_list": "Lista krajów", "country_list": "Lista krajów",

View file

@ -247,7 +247,12 @@
"price": "Pris", "price": "Pris",
"region": "Område", "region": "Område",
"reservation_number": "Bokningsnummer", "reservation_number": "Bokningsnummer",
"open_in_maps": "Kappas in" "open_in_maps": "Kappas in",
"all_day": "Hela dagen",
"collection_no_start_end_date": "Att lägga till ett start- och slutdatum till samlingen kommer att låsa upp planeringsfunktioner för resplan på insamlingssidan.",
"date_itinerary": "Datum resplan",
"no_ordered_items": "Lägg till objekt med datum i samlingen för att se dem här.",
"ordered_itinerary": "Beställd resplan"
}, },
"home": { "home": {
"desc_1": "Upptäck, planera och utforska med lätthet", "desc_1": "Upptäck, planera och utforska med lätthet",

View file

@ -295,7 +295,12 @@
"lodging_information": "住宿信息", "lodging_information": "住宿信息",
"price": "价格", "price": "价格",
"reservation_number": "预订号", "reservation_number": "预订号",
"open_in_maps": "在地图上打开" "open_in_maps": "在地图上打开",
"all_day": "整天",
"collection_no_start_end_date": "在集合页面中添加开始日期和结束日期将在“收集”页面中解锁行程计划功能。",
"date_itinerary": "日期行程",
"no_ordered_items": "将带有日期的项目添加到集合中,以便在此处查看它们。",
"ordered_itinerary": "订购了行程"
}, },
"auth": { "auth": {
"forgot_password": "忘记密码?", "forgot_password": "忘记密码?",

View file

@ -91,6 +91,7 @@
import AdventureModal from '$lib/components/AdventureModal.svelte'; import AdventureModal from '$lib/components/AdventureModal.svelte';
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import AttachmentCard from '$lib/components/AttachmentCard.svelte'; import AttachmentCard from '$lib/components/AttachmentCard.svelte';
import { isAllDay } from '$lib';
onMount(async () => { onMount(async () => {
if (data.props.adventure) { if (data.props.adventure) {
@ -410,23 +411,33 @@
</p> </p>
<!-- show each visit start and end date as well as notes --> <!-- show each visit start and end date as well as notes -->
{#each adventure.visits as visit} {#each adventure.visits as visit}
<div class="grid gap-2"> <div class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground"> <div class="flex gap-2 items-center">
{visit.start_date <p>
? new Date(visit.start_date).toLocaleDateString(undefined, { {#if isAllDay(visit.start_date)}
<!-- For all-day events, show just the date -->
{new Date(visit.start_date).toLocaleDateString(undefined, {
timeZone: 'UTC' timeZone: 'UTC'
}) })}
: ''} {:else}
{visit.end_date && <!-- For timed events, show date and time -->
visit.end_date !== '' && {new Date(visit.start_date).toLocaleDateString()} ({new Date(
visit.end_date !== visit.start_date visit.start_date
? ' - ' + ).toLocaleTimeString()})
new Date(visit.end_date).toLocaleDateString(undefined, { {/if}
</p>
{#if visit.end_date && visit.end_date !== visit.start_date}
<p>
- {new Date(visit.end_date).toLocaleDateString(undefined, {
timeZone: 'UTC' timeZone: 'UTC'
}) })}
: ''} {#if !isAllDay(visit.end_date)}
</p> ({new Date(visit.end_date).toLocaleTimeString()})
<p class="text-sm text-muted-foreground -mt-2 mb-2">{visit.notes}</p> {/if}
</p>
{/if}
</div>
<p class="whitespace-pre-wrap -mt-2 mb-2">{visit.notes}</p>
</div> </div>
{/each} {/each}
</div> </div>
@ -445,12 +456,14 @@
</div> </div>
</div> </div>
{/if} {/if}
<a {#if adventure.longitude && adventure.latitude}
class="btn btn-neutral btn-sm max-w-32" <a
href={`https://maps.apple.com/?q=${adventure.latitude},${adventure.longitude}`} class="btn btn-neutral btn-sm max-w-32"
target="_blank" href={`https://maps.apple.com/?q=${adventure.latitude},${adventure.longitude}`}
rel="noopener noreferrer">{$t('adventures.open_in_maps')}</a target="_blank"
> rel="noopener noreferrer">{$t('adventures.open_in_maps')}</a
>
{/if}
<MapLibre <MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg" class="flex items-center self-center justify-center aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"

View file

@ -208,7 +208,7 @@ export const actions: Actions = {
const order_direction = formData.get('order_direction') as string; const order_direction = formData.get('order_direction') as string;
const order_by = formData.get('order_by') as string; const order_by = formData.get('order_by') as string;
console.log(order_direction, order_by); // console.log(order_direction, order_by);
let adventures: Adventure[] = []; let adventures: Adventure[] = [];
@ -242,7 +242,7 @@ export const actions: Actions = {
previous = res.previous; previous = res.previous;
count = res.count; count = res.count;
adventures = [...adventures, ...visited]; adventures = [...adventures, ...visited];
console.log(next, previous, count); // console.log(next, previous, count);
} }
return { return {

View file

@ -15,8 +15,6 @@
let collections: Collection[] = data.props.adventures || []; let collections: Collection[] = data.props.adventures || [];
let currentSort = { attribute: 'name', order: 'asc' };
let newType: string = ''; let newType: string = '';
let resultsPerPage: number = 25; let resultsPerPage: number = 25;
@ -235,17 +233,36 @@
aria-label={$t(`adventures.descending`)} aria-label={$t(`adventures.descending`)}
/> />
</div> </div>
<p class="text-lg font-semibold mt-2 mb-2">{$t('adventures.order_by')}</p>
<div class="join">
<input
class="join-item btn btn-neutral"
type="radio"
name="order_by"
id="upated_at"
value="upated_at"
aria-label={$t('adventures.updated')}
checked
/>
<input
class="join-item btn btn-neutral"
type="radio"
name="order_by"
id="start_date"
value="start_date"
aria-label={$t('adventures.start_date')}
/>
<input
class="join-item btn btn-neutral"
type="radio"
name="order_by"
id="name"
value="name"
aria-label={$t('adventures.name')}
/>
</div>
<br /> <br />
<input
type="radio"
name="order_by"
id="name"
class="radio radio-primary"
checked
value="name"
hidden
/>
<button type="submit" class="btn btn-success btn-primary mt-4" <button type="submit" class="btn btn-success btn-primary mt-4"
>{$t(`adventures.sort`)}</button >{$t(`adventures.sort`)}</button
> >

View file

@ -133,9 +133,70 @@
} }
let currentView: string = 'itinerary'; let currentView: string = 'itinerary';
let currentItineraryView: string = 'date';
let adventures: Adventure[] = []; let adventures: Adventure[] = [];
// Add this after your existing MapLibre markers
// Add this after your existing MapLibre markers
// Create line data from orderedItems
$: lineData = createLineData(orderedItems);
// Function to create GeoJSON line data from ordered items
function createLineData(
items: Array<{
item: Adventure | Transportation | Lodging | Note | Checklist;
start: string;
end: string;
}>
) {
if (items.length < 2) return null;
const coordinates: [number, number][] = [];
// Extract coordinates from each item
for (const orderItem of items) {
const item = orderItem.item;
if (
'origin_longitude' in item &&
'origin_latitude' in item &&
'destination_longitude' in item &&
'destination_latitude' in item &&
item.origin_longitude &&
item.origin_latitude &&
item.destination_longitude &&
item.destination_latitude
) {
// For Transportation, add both origin and destination points
coordinates.push([item.origin_longitude, item.origin_latitude]);
coordinates.push([item.destination_longitude, item.destination_latitude]);
} else if ('longitude' in item && 'latitude' in item && item.longitude && item.latitude) {
// Handle Adventure and Lodging types
coordinates.push([item.longitude, item.latitude]);
}
}
// Only create line data if we have at least 2 coordinates
if (coordinates.length >= 2) {
return {
type: 'Feature' as const,
properties: {
name: 'Itinerary Path',
description: 'Path connecting chronological items'
},
geometry: {
type: 'LineString' as const,
coordinates: coordinates
}
};
}
return null;
}
let numVisited: number = 0; let numVisited: number = 0;
let numAdventures: number = 0; let numAdventures: number = 0;
@ -169,6 +230,63 @@
} }
} }
let orderedItems: Array<{
item: Adventure | Transportation | Lodging;
type: 'adventure' | 'transportation' | 'lodging';
start: string; // ISO date string
end: string; // ISO date string
}> = [];
$: {
// Reset ordered items
orderedItems = [];
// Add Adventures (using visit dates)
adventures.forEach((adventure) => {
adventure.visits.forEach((visit) => {
orderedItems.push({
item: adventure,
start: visit.start_date,
end: visit.end_date,
type: 'adventure'
});
});
});
// Add Transportation
transportations.forEach((transport) => {
if (transport.date) {
// Only add if date exists
orderedItems.push({
item: transport,
start: transport.date,
end: transport.end_date || transport.date, // Use end_date if available, otherwise use date,
type: 'transportation'
});
}
});
// Add Lodging
lodging.forEach((lodging) => {
if (lodging.check_in) {
// Only add if check_in exists
orderedItems.push({
item: lodging,
start: lodging.check_in,
end: lodging.check_out || lodging.check_in, // Use check_out if available, otherwise use check_in,
type: 'lodging'
});
}
});
// Sort all items chronologically by start date
orderedItems.sort((a, b) => {
const dateA = new Date(a.start).getTime();
const dateB = new Date(b.start).getTime();
return dateA - dateB;
});
}
$: { $: {
numAdventures = adventures.length; numAdventures = adventures.length;
numVisited = adventures.filter((adventure) => adventure.is_visited).length; numVisited = adventures.filter((adventure) => adventure.is_visited).length;
@ -186,6 +304,7 @@
} else { } else {
notFound = true; notFound = true;
} }
if (collection.start_date && collection.end_date) { if (collection.start_date && collection.end_date) {
numberOfDays = numberOfDays =
Math.floor( Math.floor(
@ -806,133 +925,265 @@
})}</span })}</span
> >
</p> </p>
<div class="join mt-2">
<input
class="join-item btn btn-neutral"
type="radio"
name="options"
aria-label={$t('adventures.date_itinerary')}
checked={currentItineraryView == 'date'}
on:change={() => (currentItineraryView = 'date')}
/>
<input
class="join-item btn btn-neutral"
type="radio"
name="options"
aria-label={$t('adventures.ordered_itinerary')}
checked={currentItineraryView == 'ordered'}
on:change={() => (currentItineraryView = 'ordered')}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="container mx-auto px-4"> {#if currentItineraryView == 'date'}
{#each Array(numberOfDays) as _, i} <div class="container mx-auto px-4">
{@const startDate = new Date(collection.start_date)} {#each Array(numberOfDays) as _, i}
{@const tempDate = new Date(startDate.getTime())} {@const startDate = new Date(collection.start_date)}
{@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))} {@const tempDate = new Date(startDate.getTime())}
{@const dateString = adjustedDate.toISOString().split('T')[0]} {@const adjustedDate = new Date(tempDate.setUTCDate(tempDate.getUTCDate() + i))}
{@const dateString = adjustedDate.toISOString().split('T')[0]}
{@const dayAdventures = {@const dayAdventures =
groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays)[ groupAdventuresByDate(adventures, new Date(collection.start_date), numberOfDays + 1)[
dateString dateString
] || []} ] || []}
{@const dayTransportations = {@const dayTransportations =
groupTransportationsByDate( groupTransportationsByDate(
transportations, transportations,
new Date(collection.start_date), new Date(collection.start_date),
numberOfDays numberOfDays + 1
)[dateString] || []} )[dateString] || []}
{@const dayLodging = {@const dayLodging =
groupLodgingByDate(lodging, new Date(collection.start_date), numberOfDays)[ groupLodgingByDate(lodging, new Date(collection.start_date), numberOfDays + 1)[
dateString dateString
] || []} ] || []}
{@const dayNotes = {@const dayNotes =
groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] || groupNotesByDate(notes, new Date(collection.start_date), numberOfDays + 1)[
[]} dateString
{@const dayChecklists = ] || []}
groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays)[ {@const dayChecklists =
dateString groupChecklistsByDate(checklists, new Date(collection.start_date), numberOfDays + 1)[
] || []} dateString
] || []}
<div class="card bg-base-100 shadow-xl my-8"> <div class="card bg-base-100 shadow-xl my-8">
<div class="card-body bg-base-200"> <div class="card-body bg-base-200">
<h2 class="card-title text-3xl justify-center g"> <h2 class="card-title text-3xl justify-center g">
{$t('adventures.day')} {$t('adventures.day')}
{i + 1} {i + 1}
<div class="badge badge-lg"> <div class="badge badge-lg">
{adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })} {adjustedDate.toLocaleDateString(undefined, { timeZone: 'UTC' })}
</div>
</h2>
<div class="divider"></div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#if dayAdventures.length > 0}
{#each dayAdventures as adventure}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
{adventure}
/>
{/each}
{/if}
{#if dayTransportations.length > 0}
{#each dayTransportations as transportation}
<TransportationCard
{transportation}
user={data?.user}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={(event) => {
transportationToEdit = event.detail;
isShowingTransportationModal = true;
}}
/>
{/each}
{/if}
{#if dayNotes.length > 0}
{#each dayNotes as note}
<NoteCard
{note}
user={data.user || null}
on:edit={(event) => {
noteToEdit = event.detail;
isNoteModalOpen = true;
}}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
/>
{/each}
{/if}
{#if dayLodging.length > 0}
{#each dayLodging as hotel}
<LodgingCard
lodging={hotel}
user={data?.user}
on:delete={(event) => {
lodging = lodging.filter((t) => t.id != event.detail);
}}
on:edit={editLodging}
/>
{/each}
{/if}
{#if dayChecklists.length > 0}
{#each dayChecklists as checklist}
<ChecklistCard
{checklist}
user={data.user || null}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
on:edit={(event) => {
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
/>
{/each}
{/if}
</div> </div>
</h2>
<div class="divider"></div> {#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0 && dayLodging.length == 0}
<p class="text-center text-lg mt-2 italic">{$t('adventures.nothing_planned')}</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#if dayAdventures.length > 0}
{#each dayAdventures as adventure}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
{adventure}
{collection}
/>
{/each}
{/if}
{#if dayTransportations.length > 0}
{#each dayTransportations as transportation}
<TransportationCard
{transportation}
{collection}
user={data?.user}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={(event) => {
transportationToEdit = event.detail;
isShowingTransportationModal = true;
}}
/>
{/each}
{/if}
{#if dayNotes.length > 0}
{#each dayNotes as note}
<NoteCard
{note}
{collection}
user={data.user || null}
on:edit={(event) => {
noteToEdit = event.detail;
isNoteModalOpen = true;
}}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
/>
{/each}
{/if}
{#if dayLodging.length > 0}
{#each dayLodging as hotel}
<LodgingCard
lodging={hotel}
{collection}
user={data?.user}
on:delete={(event) => {
lodging = lodging.filter((t) => t.id != event.detail);
}}
on:edit={editLodging}
/>
{/each}
{/if}
{#if dayChecklists.length > 0}
{#each dayChecklists as checklist}
<ChecklistCard
{checklist}
{collection}
user={data.user || null}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
on:edit={(event) => {
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
/>
{/each}
{/if} {/if}
</div> </div>
</div>
{#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0 && dayLodging.length == 0} {/each}
<p class="text-center text-lg mt-2 italic">{$t('adventures.nothing_planned')}</p> </div>
{:else}
<div class="container mx-auto px-4 py-8">
<div class="flex flex-col items-center">
<div class="w-full max-w-4xl relative">
<!-- Vertical timeline line that spans the entire height -->
{#if orderedItems.length > 0}
<div class="absolute left-8 top-0 bottom-0 w-1 bg-primary"></div>
{/if}
<ul class="relative">
{#each orderedItems as orderedItem, index}
<li class="relative pl-20 mb-8">
<!-- Timeline Icon -->
<div
class="absolute left-0 top-0 flex items-center justify-center w-16 h-16 bg-base-200 rounded-full border-2 border-primary"
>
{#if orderedItem.type === 'adventure' && orderedItem.item && 'category' in orderedItem.item && orderedItem.item.category && 'icon' in orderedItem.item.category}
<span class="text-2xl">{orderedItem.item.category.icon}</span>
{:else if orderedItem.type === 'transportation' && orderedItem.item && 'origin_latitude' in orderedItem.item}
<span class="text-2xl">{getTransportationEmoji(orderedItem.item.type)}</span
>
{:else if orderedItem.type === 'lodging' && orderedItem.item && 'reservation_number' in orderedItem.item}
<span class="text-2xl">{getLodgingIcon(orderedItem.item.type)}</span>
{/if}
</div>
<!-- Card Content -->
<div class="bg-base-200 p-6 rounded-lg shadow-lg">
<div class="flex justify-between items-center mb-4">
<span class="badge badge-lg">{$t(`adventures.${orderedItem.type}`)}</span>
<div class="text-sm opacity-80 text-right">
{new Date(orderedItem.start).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
{#if orderedItem.start !== orderedItem.end}
<div>
{new Date(orderedItem.start).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit'
})}
-
{new Date(orderedItem.end).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div>
<!-- Duration -->
{Math.round(
(new Date(orderedItem.end).getTime() -
new Date(orderedItem.start).getTime()) /
1000 /
60 /
60
)}h
{Math.round(
((new Date(orderedItem.end).getTime() -
new Date(orderedItem.start).getTime()) /
1000 /
60 /
60 -
Math.floor(
(new Date(orderedItem.end).getTime() -
new Date(orderedItem.start).getTime()) /
1000 /
60 /
60
)) *
60
)}m
</div>
{:else}
<p>{$t('adventures.all_day')} ⏱️</p>
{/if}
</div>
</div>
{#if orderedItem.type === 'adventure' && orderedItem.item && 'images' in orderedItem.item}
<AdventureCard
user={data.user}
on:edit={editAdventure}
on:delete={deleteAdventure}
adventure={orderedItem.item}
{collection}
/>
{:else if orderedItem.type === 'transportation' && orderedItem.item && 'origin_latitude' in orderedItem.item}
<TransportationCard
transportation={orderedItem.item}
user={data?.user}
on:delete={(event) => {
transportations = transportations.filter((t) => t.id != event.detail);
}}
on:edit={editTransportation}
{collection}
/>
{:else if orderedItem.type === 'lodging' && orderedItem.item && 'reservation_number' in orderedItem.item}
<LodgingCard
lodging={orderedItem.item}
user={data?.user}
on:delete={(event) => {
lodging = lodging.filter((t) => t.id != event.detail);
}}
on:edit={editLodging}
{collection}
/>
{/if}
</div>
</li>
{/each}
</ul>
{#if orderedItems.length === 0}
<div class="alert alert-info">
<p class="text-center text-lg">{$t('adventures.no_ordered_items')}</p>
</div>
{/if} {/if}
</div> </div>
</div> </div>
{/each} </div>
</div> {/if}
{/if} {/if}
{/if} {/if}
@ -999,6 +1250,19 @@
</Marker> </Marker>
{/if} {/if}
{/each} {/each}
{#if lineData}
<GeoJSON data={lineData}>
<LineLayer
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
paint={{
'line-width': 4,
'line-color': '#0088CC', // Blue line to distinguish from transportation lines
'line-opacity': 0.8,
'line-dasharray': [2, 1] // Dashed line to differentiate from direct transportation lines
}}
/>
</GeoJSON>
{/if}
{#each transportations as transportation} {#each transportations as transportation}
{#if transportation.origin_latitude && transportation.origin_longitude && transportation.destination_latitude && transportation.destination_longitude} {#if transportation.origin_latitude && transportation.origin_longitude && transportation.destination_latitude && transportation.destination_longitude}
<!-- Origin Marker --> <!-- Origin Marker -->
@ -1040,34 +1304,6 @@
</p> </p>
</Popup> </Popup>
</Marker> </Marker>
<!-- Line connecting origin and destination -->
<GeoJSON
data={{
type: 'Feature',
properties: {
name: transportation.name,
type: transportation.type
},
geometry: {
type: 'LineString',
coordinates: [
[transportation.origin_longitude, transportation.origin_latitude],
[transportation.destination_longitude, transportation.destination_latitude]
]
}
}}
>
<LineLayer
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
paint={{
'line-width': 3,
'line-color': '#898989', // customize your line color here
'line-opacity': 0.8
// 'line-dasharray': [5, 2]
}}
/>
</GeoJSON>
{/if} {/if}
{/each} {/each}
@ -1233,13 +1469,16 @@
{recomendation.name || $t('recomendations.recommendation')} {recomendation.name || $t('recomendations.recommendation')}
</h2> </h2>
<div class="badge badge-primary">{recomendation.tag}</div> <div class="badge badge-primary">{recomendation.tag}</div>
{#if recomendation.address} {#if recomendation.address && (recomendation.address.housenumber || recomendation.address.street || recomendation.address.city || recomendation.address.state || recomendation.address.postcode)}
<p class="text-md"> <p class="text-md">
<strong>{$t('recomendations.address')}:</strong> <strong>{$t('recomendations.address')}:</strong>
{recomendation.address.housenumber} {#if recomendation.address.housenumber}{recomendation.address
{recomendation.address.street}, {recomendation.address.city}, {recomendation .housenumber}{/if}
.address.state} {#if recomendation.address.street}
{recomendation.address.postcode} {recomendation.address.street}{/if}
{#if recomendation.address.city}, {recomendation.address.city}{/if}
{#if recomendation.address.state}, {recomendation.address.state}{/if}
{#if recomendation.address.postcode}, {recomendation.address.postcode}{/if}
</p> </p>
{/if} {/if}
{#if recomendation.contact} {#if recomendation.contact}

View file

@ -74,8 +74,6 @@ export const actions: Actions = {
} else { } else {
const setCookieHeader = loginFetch.headers.get('Set-Cookie'); const setCookieHeader = loginFetch.headers.get('Set-Cookie');
console.log('setCookieHeader:', setCookieHeader);
if (setCookieHeader) { if (setCookieHeader) {
// Regular expression to match sessionid cookie and its expiry // Regular expression to match sessionid cookie and its expiry
const sessionIdRegex = /sessionid=([^;]+).*?expires=([^;]+)/; const sessionIdRegex = /sessionid=([^;]+).*?expires=([^;]+)/;

View file

@ -164,6 +164,14 @@
{#if filteredCountries.length === 0} {#if filteredCountries.length === 0}
<p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_countries_found')}</p> <p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_countries_found')}</p>
<div class="text-center mt-4">
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/updating.html#updating-the-region-data"
target="_blank">{$t('settings.documentation_link')}</a
>
</div>
{/if} {/if}
<svelte:head> <svelte:head>