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

feat: Add TimezoneSelector component and integrate Luxon for date handling

This commit is contained in:
Sean Morley 2025-04-18 23:06:36 -04:00
parent 7499722867
commit 6942f5e1bb
4 changed files with 221 additions and 48 deletions

View file

@ -43,6 +43,7 @@
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"emoji-picker-element": "^1.26.0", "emoji-picker-element": "^1.26.0",
"gsap": "^3.12.7", "gsap": "^3.12.7",
"luxon": "^3.6.1",
"marked": "^15.0.4", "marked": "^15.0.4",
"psl": "^1.15.0", "psl": "^1.15.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

View file

@ -23,6 +23,9 @@ importers:
gsap: gsap:
specifier: ^3.12.7 specifier: ^3.12.7
version: 3.12.7 version: 3.12.7
luxon:
specifier: ^3.6.1
version: 3.6.1
marked: marked:
specifier: ^15.0.4 specifier: ^15.0.4
version: 15.0.4 version: 15.0.4
@ -1469,6 +1472,10 @@ packages:
lru-queue@0.1.0: lru-queue@0.1.0:
resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==}
luxon@3.6.1:
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
engines: {node: '>=12'}
magic-string@0.30.10: magic-string@0.30.10:
resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==}
@ -3514,6 +3521,8 @@ snapshots:
dependencies: dependencies:
es5-ext: 0.10.64 es5-ext: 0.10.64
luxon@3.6.1: {}
magic-string@0.30.10: magic-string@0.30.10:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15

View file

@ -0,0 +1,92 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
export let selectedTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
let dropdownOpen = false;
let searchQuery = '';
const timezones = Intl.supportedValuesOf('timeZone');
// Filter timezones based on search query
$: filteredTimezones = searchQuery
? timezones.filter((tz) => tz.toLowerCase().includes(searchQuery.toLowerCase()))
: timezones;
function selectTimezone(tz: string) {
selectedTimezone = tz;
dropdownOpen = false;
searchQuery = '';
}
// Close dropdown if clicked outside
onMount(() => {
const handleClickOutside = (e: MouseEvent) => {
const dropdown = document.getElementById('tz-selector');
if (dropdown && !dropdown.contains(e.target as Node)) dropdownOpen = false;
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
</script>
<div class="form-control w-full max-w-xs relative" id="tz-selector">
<label class="label">
<span class="label-text">Timezone</span>
</label>
<!-- Trigger -->
<div
tabindex="0"
role="button"
class="input input-bordered flex justify-between items-center cursor-pointer"
on:click={() => (dropdownOpen = !dropdownOpen)}
>
<span class="truncate">{selectedTimezone}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<!-- Dropdown -->
{#if dropdownOpen}
<div
class="absolute mt-1 z-10 bg-base-100 shadow-lg rounded-box w-full max-h-60 overflow-y-auto"
>
<!-- Search -->
<div class="sticky top-0 bg-base-100 p-2 border-b">
<input
type="text"
placeholder="Search timezone"
class="input input-sm input-bordered w-full"
bind:value={searchQuery}
autofocus
/>
</div>
<!-- Timezone list -->
{#if filteredTimezones.length > 0}
<ul class="menu p-2 space-y-1">
{#each filteredTimezones as tz}
<li>
<a
class={`truncate ${tz === selectedTimezone ? 'active font-bold' : ''}`}
on:click|preventDefault={() => selectTimezone(tz)}
>
{tz}
</a>
</li>
{/each}
</ul>
{:else}
<div class="p-2 text-sm text-center opacity-60">No timezones found</div>
{/if}
</div>
{/if}
</div>

View file

@ -6,36 +6,53 @@
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
let modal: HTMLDialogElement; let modal: HTMLDialogElement;
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
// @ts-ignore
import { DateTime } from 'luxon';
// Initialize with browser's timezone
let selectedTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Store the UTC dates as source of truth
let utcStartDate: string | null = null;
let utcEndDate: string | null = null;
// Local display values
let localStartDate: string = '';
let localEndDate: string = '';
import MarkdownEditor from './MarkdownEditor.svelte'; import MarkdownEditor from './MarkdownEditor.svelte';
import { appVersion } from '$lib/config'; import { appVersion } from '$lib/config';
import { DefaultMarker, MapLibre } from 'svelte-maplibre'; import { DefaultMarker, MapLibre } from 'svelte-maplibre';
import TimezoneSelector from './TimezoneSelector.svelte';
export let collection: Collection; export let collection: Collection;
export let transportationToEdit: Transportation | null = null; export let transportationToEdit: Transportation | null = null;
let constrainDates: boolean = false; let constrainDates: boolean = false;
// Format date as local datetime // Convert a UTC ISO date to a datetime-local value in the specified timezone
// Convert an ISO date to a datetime-local value in local time. function toLocalDatetime(utcDate: string | null, timezone: string = selectedTimezone): string {
function toLocalDatetime(value: string | null): string { if (!utcDate) return '';
if (!value) return ''; return DateTime.fromISO(utcDate, { zone: 'UTC' })
const date = new Date(value); .setZone(timezone)
// Adjust the time by subtracting the timezone offset. .toISO({ suppressSeconds: true, includeOffset: false })
date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); .slice(0, 16);
// Return format YYYY-MM-DDTHH:mm
return date.toISOString().slice(0, 16);
} }
// Convert a local datetime to UTC
function toUTCDatetime(localDate: string, timezone: string = selectedTimezone): string | null {
if (!localDate) return null;
return DateTime.fromISO(localDate, { zone: timezone }).toUTC().toISO();
}
// Initialize transportation object
let transportation: Transportation = { let transportation: Transportation = {
id: transportationToEdit?.id || '', id: transportationToEdit?.id || '',
type: transportationToEdit?.type || '', type: transportationToEdit?.type || '',
name: transportationToEdit?.name || '', name: transportationToEdit?.name || '',
description: transportationToEdit?.description || '', description: transportationToEdit?.description || '',
date: transportationToEdit?.date ? toLocalDatetime(transportationToEdit.date) : null, date: null,
end_date: transportationToEdit?.end_date end_date: null,
? toLocalDatetime(transportationToEdit.end_date)
: null,
rating: transportationToEdit?.rating || 0, rating: transportationToEdit?.rating || 0,
link: transportationToEdit?.link || '', link: transportationToEdit?.link || '',
flight_number: transportationToEdit?.flight_number || '', flight_number: transportationToEdit?.flight_number || '',
@ -69,13 +86,44 @@
} }
} }
console.log(transportation); // Update local display dates whenever timezone or UTC dates change
$: {
if (utcStartDate) {
localStartDate = toLocalDatetime(utcStartDate, selectedTimezone);
}
if (utcEndDate) {
localEndDate = toLocalDatetime(utcEndDate, selectedTimezone);
}
}
// Explicitly watch for timezone changes to update displayed dates
$: {
// This will trigger whenever selectedTimezone changes
selectedTimezone;
if (utcStartDate) {
localStartDate = toLocalDatetime(utcStartDate);
}
if (utcEndDate) {
localEndDate = toLocalDatetime(utcEndDate);
}
}
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
modal.showModal(); modal.showModal();
} }
// Initialize UTC dates from transportationToEdit if available
if (transportationToEdit?.date) {
utcStartDate = transportationToEdit.date;
localStartDate = toLocalDatetime(utcStartDate);
}
if (transportationToEdit?.end_date) {
utcEndDate = transportationToEdit.end_date;
localEndDate = toLocalDatetime(utcEndDate);
}
}); });
function close() { function close() {
@ -88,6 +136,12 @@
} }
} }
// Update UTC dates when local dates change
function updateUTCDates() {
utcStartDate = localStartDate ? toUTCDatetime(localStartDate) : null;
utcEndDate = localEndDate ? toUTCDatetime(localEndDate) : null;
}
async function geocode(e: Event | null) { async function geocode(e: Event | null) {
if (e) { if (e) {
e.preventDefault(); e.preventDefault();
@ -172,47 +226,56 @@
Math.round(transportation.destination_longitude * 1e6) / 1e6; Math.round(transportation.destination_longitude * 1e6) / 1e6;
} }
if (transportation.end_date && !transportation.date) { // Validate dates
transportation.date = null; if (localEndDate && !localStartDate) {
transportation.end_date = null; addToast('error', $t('adventures.start_date_required'));
return;
} }
if (transportation.date && !transportation.end_date) { if (localStartDate && !localEndDate) {
transportation.end_date = transportation.date; // If only start date is provided, set end date to the same value
localEndDate = localStartDate;
utcEndDate = utcStartDate;
} }
if ( if (
transportation.date && localStartDate &&
transportation.end_date && localEndDate &&
transportation.date > transportation.end_date DateTime.fromISO(localStartDate).toMillis() > DateTime.fromISO(localEndDate).toMillis()
) { ) {
addToast('error', $t('adventures.start_before_end_error')); addToast('error', $t('adventures.start_before_end_error'));
return; return;
} }
// Convert local dates to UTC // Use the stored UTC dates for submission
if (transportation.date && !transportation.date.includes('Z')) { const submissionData = {
transportation.date = new Date(transportation.date).toISOString(); ...transportation,
} date: utcStartDate,
if (transportation.end_date && !transportation.end_date.includes('Z')) { end_date: utcEndDate
transportation.end_date = new Date(transportation.end_date).toISOString(); };
}
if (transportation.type != 'plane') { if (transportation.type != 'plane') {
transportation.flight_number = ''; submissionData.flight_number = '';
} }
if (transportation.id === '') { if (submissionData.id === '') {
let res = await fetch('/api/transportations', { let res = await fetch('/api/transportations', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(transportation) body: JSON.stringify(submissionData)
}); });
let data = await res.json(); let data = await res.json();
if (data.id) { if (data.id) {
transportation = data as Transportation; transportation = data as Transportation;
// Update the UTC dates with the values from the server
utcStartDate = data.date;
utcEndDate = data.end_date;
// Update displayed dates
localStartDate = toLocalDatetime(utcStartDate);
localEndDate = toLocalDatetime(utcEndDate);
addToast('success', $t('adventures.adventure_created')); addToast('success', $t('adventures.adventure_created'));
dispatch('save', transportation); dispatch('save', transportation);
} else { } else {
@ -225,11 +288,18 @@
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(transportation) body: JSON.stringify(submissionData)
}); });
let data = await res.json(); let data = await res.json();
if (data.id) { if (data.id) {
transportation = data as Transportation; transportation = data as Transportation;
// Update the UTC dates with the values from the server
utcStartDate = data.date;
utcEndDate = data.end_date;
// Update displayed dates
localStartDate = toLocalDatetime(utcStartDate);
localEndDate = toLocalDatetime(utcEndDate);
addToast('success', $t('adventures.adventure_updated')); addToast('success', $t('adventures.adventure_updated'));
dispatch('save', transportation); dispatch('save', transportation);
} else { } else {
@ -385,6 +455,7 @@
{$t('adventures.date_information')} {$t('adventures.date_information')}
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<TimezoneSelector bind:selectedTimezone />
<!-- Start Date --> <!-- Start Date -->
<div> <div>
<label for="date"> <label for="date">
@ -409,7 +480,8 @@
type="datetime-local" type="datetime-local"
id="date" id="date"
name="date" name="date"
bind:value={transportation.date} bind:value={localStartDate}
on:change={updateUTCDates}
min={constrainDates ? fullStartDate : ''} min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''} max={constrainDates ? fullEndDate : ''}
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs mt-1"
@ -417,7 +489,7 @@
</div> </div>
</div> </div>
<!-- End Date --> <!-- End Date -->
{#if transportation.date} {#if localStartDate}
<div> <div>
<label for="end_date"> <label for="end_date">
{$t('adventures.end_date')} {$t('adventures.end_date')}
@ -427,9 +499,10 @@
type="datetime-local" type="datetime-local"
id="end_date" id="end_date"
name="end_date" name="end_date"
min={constrainDates ? transportation.date : ''} min={constrainDates ? localStartDate : ''}
max={constrainDates ? fullEndDate : ''} max={constrainDates ? fullEndDate : ''}
bind:value={transportation.end_date} bind:value={localEndDate}
on:change={updateUTCDates}
class="input input-bordered w-full max-w-xs mt-1" class="input input-bordered w-full max-w-xs mt-1"
/> />
</div> </div>
@ -451,13 +524,17 @@
</svg> </svg>
<span> <span>
{$t('lodging.current_timezone')}: {$t('lodging.current_timezone')}:
{(() => { {selectedTimezone}
const tz = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const [continent, city] = tz.split('/');
return `${continent} (${city.replace('_', ' ')})`;
})()}
</span> </span>
</div> </div>
{#if utcStartDate}
<div class="text-sm mt-2">
UTC Time: {DateTime.fromISO(utcStartDate).toISO().slice(0, 16).replace('T', ' ')}
{#if utcEndDate && utcEndDate !== utcStartDate}
to {DateTime.fromISO(utcEndDate).toISO().slice(0, 16).replace('T', ' ')}
{/if}
</div>
{/if}
</div> </div>
</div> </div>
@ -585,11 +662,6 @@
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg" class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls standardControls
> >
<!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. -->
<!-- @ts-ignore -->
{#if transportation.origin_latitude && transportation.origin_longitude} {#if transportation.origin_latitude && transportation.origin_longitude}
<DefaultMarker <DefaultMarker
lngLat={[transportation.origin_longitude, transportation.origin_latitude]} lngLat={[transportation.origin_longitude, transportation.origin_latitude]}
@ -604,7 +676,6 @@ it would also work to just use on:click on the MapLibre component itself. -->
/> />
{/if} {/if}
</MapLibre> </MapLibre>
<!-- button to clear to and from location -->
</div> </div>
{#if transportation.from_location || transportation.to_location} {#if transportation.from_location || transportation.to_location}
<button <button