1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-18 12:29:37 +02:00

Merge pull request #680 from seanmorley15/development

Date and Timezone fixes (lots of them!!)
This commit is contained in:
Sean Morley 2025-06-19 11:54:41 -04:00 committed by GitHub
commit cadea118d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1112 additions and 206 deletions

View file

@ -14,6 +14,7 @@
"devDependencies": {
"@event-calendar/core": "^3.7.1",
"@event-calendar/day-grid": "^3.7.1",
"@event-calendar/interaction": "^3.12.0",
"@event-calendar/time-grid": "^3.7.1",
"@iconify-json/mdi": "^1.1.67",
"@sveltejs/adapter-node": "^5.2.0",

View file

@ -48,6 +48,9 @@ importers:
'@event-calendar/day-grid':
specifier: ^3.7.1
version: 3.12.0
'@event-calendar/interaction':
specifier: ^3.12.0
version: 3.12.0
'@event-calendar/time-grid':
specifier: ^3.7.1
version: 3.12.0
@ -566,6 +569,9 @@ packages:
'@event-calendar/day-grid@3.12.0':
resolution: {integrity: sha512-gY6XvEIlwWI9uKWsXukyanDmrEWv1UDHdhikhchpe6iZP25p3+760qXIU2kdu91tXjb+hVbpFcn7sdNPPE4u7Q==}
'@event-calendar/interaction@3.12.0':
resolution: {integrity: sha512-+d3KqxNdcY/RfJrdai37XCoTx7KKpzqJIo/WAjH1p8ZiypsfrHgpWWuTtF76u3hpn/1qqWUM3VFJSTKbjJkWTg==}
'@event-calendar/time-grid@3.12.0':
resolution: {integrity: sha512-n/IoFSq/ym6ad2k+H9RL2A8GpfOJy1zpKKLb1Edp/QEusexpPg8LNdSbxhmKGz6ip5ud0Bi/xgUa8xUqut8ooQ==}
@ -2405,6 +2411,11 @@ snapshots:
'@event-calendar/core': 3.12.0
svelte: 4.2.19
'@event-calendar/interaction@3.12.0':
dependencies:
'@event-calendar/core': 3.12.0
svelte: 4.2.19
'@event-calendar/time-grid@3.12.0':
dependencies:
'@event-calendar/core': 3.12.0

View file

@ -12,8 +12,6 @@
import Search from '~icons/mdi/magnify';
import Clear from '~icons/mdi/close';
import Link from '~icons/mdi/link-variant';
import Filter from '~icons/mdi/filter-variant';
import Calendar from '~icons/mdi/calendar';
import Check from '~icons/mdi/check-circle';
import Cancel from '~icons/mdi/cancel';
import Public from '~icons/mdi/earth';

View file

@ -51,6 +51,11 @@
let isEditing = false; // Disable reactivity when editing
onMount(async () => {
// Auto-detect all-day for transportation and lodging types
if ((type === 'transportation' || type === 'lodging') && utcStartDate) {
allDay = isAllDay(utcStartDate);
}
// Initialize UTC dates
localStartDate = updateLocalDate({
utcDate: utcStartDate,
@ -263,7 +268,9 @@
<label for="date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.departure_date')
: $t('adventures.start_date')}
: type === 'lodging'
? $t('adventures.check_in')
: $t('adventures.start_date')}
</label>
{#if allDay}
@ -295,7 +302,11 @@
{#if localStartDate}
<div class="space-y-2">
<label for="end_date" class="text-sm font-medium">
{type === 'transportation' ? $t('adventures.arrival_date') : $t('adventures.end_date')}
{type === 'transportation'
? $t('adventures.arrival_date')
: type === 'lodging'
? $t('adventures.check_out')
: $t('adventures.end_date')}
</label>
{#if allDay}

View file

@ -8,6 +8,8 @@
import DeleteWarning from './DeleteWarning.svelte';
import { LODGING_TYPES_ICONS } from '$lib';
import { formatDateInTimezone } from '$lib/dateUtils';
import { formatAllDayDate } from '$lib/dateUtils';
import { isAllDay } from '$lib';
const dispatch = createEventDispatcher();
@ -96,8 +98,8 @@
>
<div class="card-body p-6 space-y-4">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<h2 class="text-xl font-bold truncate">{lodging.name}</h2>
<div class="flex flex-col gap-3">
<h2 class="text-xl font-bold break-words">{lodging.name}</h2>
<div class="flex flex-wrap gap-2">
<div class="badge badge-secondary">
{$t(`lodging.${lodging.type}`)}
@ -118,18 +120,39 @@
</div>
{/if}
{#if lodging.check_in && lodging.check_out}
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{$t('adventures.dates')}:</span>
<p class="text-sm">
{formatDateInTimezone(lodging.check_in, lodging.timezone)}
{formatDateInTimezone(lodging.check_out, lodging.timezone)}
{#if lodging.timezone}
<span class="ml-1 text-xs opacity-60">({lodging.timezone})</span>
{/if}
</p>
</div>
{/if}
<div class="space-y-3">
{#if lodging.check_in}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.check_in')}:</span>
<span>
{#if isAllDay(lodging.check_in)}
{formatAllDayDate(lodging.check_in)}
{:else}
{formatDateInTimezone(lodging.check_in, lodging.timezone)}
{#if lodging.timezone}
<span class="ml-1 text-xs opacity-60">({lodging.timezone})</span>
{/if}
{/if}
</span>
</div>
{/if}
{#if lodging.check_out}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.check_out')}:</span>
<span>
{#if isAllDay(lodging.check_out)}
{formatAllDayDate(lodging.check_out)}
{:else}
{formatDateInTimezone(lodging.check_out, lodging.timezone)}
{#if lodging.timezone}
<span class="ml-1 text-xs opacity-60">({lodging.timezone})</span>
{/if}
{/if}
</span>
</div>
{/if}
</div>
</div>
<!-- Reservation Info -->

View file

@ -6,6 +6,9 @@
import type { Collection, Lodging } from '$lib/types';
import LocationDropdown from './LocationDropdown.svelte';
import DateRangeCollapse from './DateRangeCollapse.svelte';
import { isAllDay } from '$lib';
// @ts-ignore
import { DateTime } from 'luxon';
const dispatch = createEventDispatcher();
@ -22,19 +25,7 @@
label: string;
};
const LODGING_TYPES: LodgingType[] = [
{ value: 'hotel', label: 'Hotel' },
{ value: 'hostel', label: 'Hostel' },
{ value: 'resort', label: 'Resort' },
{ value: 'bnb', label: 'Bed & Breakfast' },
{ value: 'campground', label: 'Campground' },
{ value: 'cabin', label: 'Cabin' },
{ value: 'apartment', label: 'Apartment' },
{ value: 'house', label: 'House' },
{ value: 'villa', label: 'Villa' },
{ value: 'motel', label: 'Motel' },
{ value: 'other', label: 'Other' }
];
let lodgingTimezone: string | undefined = lodging.timezone ?? undefined;
// Initialize hotel with values from lodgingToEdit or default values
function initializeLodging(lodgingToEdit: Lodging | null): Lodging {
@ -94,6 +85,25 @@
async function handleSubmit(event: Event) {
event.preventDefault();
lodging.timezone = lodgingTimezone || null;
// Auto-set end date if missing but start date exists
// If check_out is not set, we will set it to the next day at 9:00 AM in the lodging's timezone if it is a timed event. If it is an all-day event, we will set it to the next day at UTC 00:00:00.
if (lodging.check_in && !lodging.check_out) {
if (isAllDay(lodging.check_in)) {
// For all-day, just add one day and keep at UTC 00:00:00
const start = DateTime.fromISO(lodging.check_in, { zone: 'utc' });
const nextDay = start.plus({ days: 1 });
lodging.check_out = nextDay.toISO();
} else {
// For timed events, set to next day at 9:00 AM in lodging's timezone, then convert to UTC
const start = DateTime.fromISO(lodging.check_in, { zone: lodging.timezone || 'utc' });
const nextDay = start.plus({ days: 1 });
const end = nextDay.set({ hour: 9, minute: 0, second: 0, millisecond: 0 });
lodging.check_out = end.toUTC().toISO();
}
}
// Create or update lodging...
const url = lodging.id === '' ? '/api/lodging' : `/api/lodging/${lodging.id}`;
const method = lodging.id === '' ? 'POST' : 'PATCH';
@ -304,7 +314,8 @@
type="lodging"
bind:utcStartDate={lodging.check_in}
bind:utcEndDate={lodging.check_out}
bind:selectedStartTimezone={lodging.timezone}
bind:selectedStartTimezone={lodgingTimezone}
{collection}
/>
<!-- Location Information -->

View file

@ -8,7 +8,8 @@
import DeleteWarning from './DeleteWarning.svelte';
// import ArrowDownThick from '~icons/mdi/arrow-down-thick';
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
import { formatDateInTimezone } from '$lib/dateUtils';
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
import { isAllDay } from '$lib';
function getTransportationIcon(type: string) {
if (type in TRANSPORTATION_TYPES_ICONS) {
@ -161,9 +162,13 @@
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.start')}:</span>
<span>
{formatDateInTimezone(transportation.date, transportation.start_timezone)}
{#if transportation.start_timezone}
<span class="ml-1 text-xs opacity-60">({transportation.start_timezone})</span>
{#if isAllDay(transportation.date) && (!transportation.end_date || isAllDay(transportation.end_date))}
{formatAllDayDate(transportation.date)}
{:else}
{formatDateInTimezone(transportation.date, transportation.start_timezone)}
{#if transportation.start_timezone}
<span class="ml-1 text-xs opacity-60">({transportation.start_timezone})</span>
{/if}
{/if}
</span>
</div>
@ -173,9 +178,13 @@
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.end')}:</span>
<span>
{formatDateInTimezone(transportation.end_date, transportation.end_timezone)}
{#if transportation.end_timezone}
<span class="ml-1 text-xs opacity-60">({transportation.end_timezone})</span>
{#if isAllDay(transportation.end_date) && (!transportation.date || isAllDay(transportation.date))}
{formatAllDayDate(transportation.end_date)}
{:else}
{formatDateInTimezone(transportation.end_date, transportation.end_timezone)}
{#if transportation.end_timezone}
<span class="ml-1 text-xs opacity-60">({transportation.end_timezone})</span>
{/if}
{/if}
</span>
</div>

View file

@ -1,4 +1,4 @@
export let appVersion = 'v0.10.0-main-06152025';
export let appVersion = 'v0.10.0-main-06192025';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.10.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025';

View file

@ -147,6 +147,25 @@ export function formatUTCDate(utcDate: string | null): string {
return dateTime.toISO()?.slice(0, 16).replace('T', ' ') || '';
}
/**
* Format all-day date for display without timezone conversion
* @param dateString - Date string in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)
* @returns Formatted date string (e.g., "Jun 1, 2025")
*/
export function formatAllDayDate(dateString: string): string {
if (!dateString) return '';
// Extract just the date part and add midday time to avoid timezone issues
const datePart = dateString.split('T')[0];
const dateWithMidday = `${datePart}T12:00:00`;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(dateWithMidday));
}
export const VALID_TIMEZONES = [
'Africa/Abidjan',
'Africa/Accra',

View file

@ -1,5 +1,8 @@
import inspirationalQuotes from './json/quotes.json';
import randomBackgrounds from './json/backgrounds.json';
// @ts-ignore
import { DateTime } from 'luxon';
import type {
Adventure,
Background,
@ -67,66 +70,55 @@ export function groupAdventuresByDate(
): Record<string, Adventure[]> {
const groupedAdventures: Record<string, Adventure[]> = {};
// Initialize all days in the range
// Initialize all days in the range using DateTime
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateString = getLocalDateString(currentDate);
const currentDate = DateTime.fromJSDate(startDate).plus({ days: i });
const dateString = currentDate.toISODate(); // 'YYYY-MM-DD'
groupedAdventures[dateString] = [];
}
adventures.forEach((adventure) => {
adventure.visits.forEach((visit) => {
if (visit.start_date) {
// Check if this is an all-day event (both start and end at midnight)
const isAllDayEvent =
isAllDay(visit.start_date) && (visit.end_date ? isAllDay(visit.end_date) : false);
// Check if it's all-day: start has 00:00:00 AND (no end OR end also has 00:00:00)
const startHasZeros = isAllDay(visit.start_date);
const endHasZeros = visit.end_date ? isAllDay(visit.end_date) : true;
const isAllDayEvent = startHasZeros && endHasZeros;
// 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];
let startDT: DateTime;
let endDT: DateTime;
// Loop through all days in the range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const currentDateStr = getLocalDateString(currentDate);
if (isAllDayEvent) {
// For all-day events, extract just the date part and ignore timezone
const dateOnly = visit.start_date.split('T')[0]; // Get 'YYYY-MM-DD'
startDT = DateTime.fromISO(dateOnly); // This creates a date without time/timezone
// Include the current day if it falls within the adventure date range
if (currentDateStr >= startDateStr && currentDateStr <= endDateStr) {
if (groupedAdventures[currentDateStr]) {
groupedAdventures[currentDateStr].push(adventure);
}
}
}
endDT = visit.end_date ? DateTime.fromISO(visit.end_date.split('T')[0]) : startDT;
} else {
// Handle regular events with time components
const adventureStartDate = new Date(visit.start_date);
const adventureDateStr = getLocalDateString(adventureStartDate);
// For timed events, use timezone conversion
startDT = DateTime.fromISO(visit.start_date, {
zone: visit.timezone ?? 'UTC'
});
if (visit.end_date) {
const adventureEndDate = new Date(visit.end_date);
const endDateStr = getLocalDateString(adventureEndDate);
endDT = visit.end_date
? DateTime.fromISO(visit.end_date, {
zone: visit.timezone ?? 'UTC'
})
: startDT;
}
// 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);
const startDateStr = startDT.toISODate();
const endDateStr = endDT.toISODate();
// 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);
// Loop through all days in range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = DateTime.fromJSDate(startDate).plus({ days: i });
const currentDateStr = currentDate.toISODate();
// Include the current day if it falls within the adventure date range
if (currentDateStr >= startDateStr && currentDateStr <= endDateStr) {
if (groupedAdventures[currentDateStr]) {
groupedAdventures[currentDateStr].push(adventure);
}
}
}
@ -144,13 +136,6 @@ function getLocalDateString(date: Date): string {
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(
transportations: Transportation[],
startDate: Date,
@ -158,82 +143,127 @@ export function groupTransportationsByDate(
): Record<string, Transportation[]> {
const groupedTransportations: Record<string, Transportation[]> = {};
// Initialize all days in the range
// Initialize days
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateString = getLocalDateString(currentDate);
const currentDate = DateTime.fromJSDate(startDate).plus({ days: i });
const dateString = currentDate.toISODate(); // 'YYYY-MM-DD'
groupedTransportations[dateString] = [];
}
transportations.forEach((transportation) => {
if (transportation.date) {
const transportationDate = getLocalDateString(new Date(transportation.date));
if (transportation.end_date) {
const endDate = new Date(transportation.end_date).toISOString().split('T')[0];
// Check if it's all-day: start has 00:00:00 AND (no end OR end also has 00:00:00)
const startHasZeros = transportation.date.includes('T00:00:00');
const endHasZeros = transportation.end_date
? transportation.end_date.includes('T00:00:00')
: true;
const isTranspoAllDay = startHasZeros && endHasZeros;
// Loop through all days and include transportation 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);
let startDT: DateTime;
let endDT: DateTime;
// Include the current day if it falls within the transportation date range
if (dateString >= transportationDate && dateString <= endDate) {
if (groupedTransportations[dateString]) {
groupedTransportations[dateString].push(transportation);
}
}
if (isTranspoAllDay) {
// For all-day events, extract just the date part and ignore timezone
const dateOnly = transportation.date.split('T')[0]; // Get 'YYYY-MM-DD'
startDT = DateTime.fromISO(dateOnly); // This creates a date without time/timezone
endDT = transportation.end_date
? DateTime.fromISO(transportation.end_date.split('T')[0])
: startDT;
} else {
// For timed events, use timezone conversion
startDT = DateTime.fromISO(transportation.date, {
zone: transportation.start_timezone ?? 'UTC'
});
endDT = transportation.end_date
? DateTime.fromISO(transportation.end_date, {
zone: transportation.end_timezone ?? transportation.start_timezone ?? 'UTC'
})
: startDT;
}
const startDateStr = startDT.toISODate();
const endDateStr = endDT.toISODate();
// Loop through all days in range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = DateTime.fromJSDate(startDate).plus({ days: i });
const currentDateStr = currentDate.toISODate();
if (currentDateStr >= startDateStr && currentDateStr <= endDateStr) {
groupedTransportations[currentDateStr]?.push(transportation);
}
} else if (groupedTransportations[transportationDate]) {
// If there's no end date, add transportation to the start date only
groupedTransportations[transportationDate].push(transportation);
}
}
});
return groupedTransportations;
}
export function groupLodgingByDate(
transportations: Lodging[],
lodging: Lodging[],
startDate: Date,
numberOfDays: number
): Record<string, Lodging[]> {
const groupedTransportations: Record<string, Lodging[]> = {};
const groupedLodging: Record<string, Lodging[]> = {};
// Initialize all days in the range using local dates
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateString = getLocalDateString(currentDate);
groupedTransportations[dateString] = [];
// Initialize days (excluding last day for lodging)
// If trip is 7/1 to 7/4 (4 days), show lodging only on 7/1, 7/2, 7/3
const lodgingDays = numberOfDays - 1;
for (let i = 0; i < lodgingDays; i++) {
const currentDate = DateTime.fromJSDate(startDate).plus({ days: i });
const dateString = currentDate.toISODate(); // 'YYYY-MM-DD'
groupedLodging[dateString] = [];
}
transportations.forEach((transportation) => {
if (transportation.check_in) {
// Use local date string conversion
const transportationDate = getLocalDateString(new Date(transportation.check_in));
if (transportation.check_out) {
const endDate = getLocalDateString(new Date(transportation.check_out));
lodging.forEach((hotel) => {
if (hotel.check_in) {
// Check if it's all-day: start has 00:00:00 AND (no end OR end also has 00:00:00)
const startHasZeros = hotel.check_in.includes('T00:00:00');
const endHasZeros = hotel.check_out ? hotel.check_out.includes('T00:00:00') : true;
const isAllDay = startHasZeros && endHasZeros;
// Loop through all days and include transportation if it falls within the transportation date range
for (let i = 0; i < numberOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateString = getLocalDateString(currentDate);
let startDT: DateTime;
let endDT: DateTime;
if (dateString >= transportationDate && dateString <= endDate) {
groupedTransportations[dateString].push(transportation);
}
if (isAllDay) {
// For all-day events, extract just the date part and ignore timezone
const dateOnly = hotel.check_in.split('T')[0]; // Get 'YYYY-MM-DD'
startDT = DateTime.fromISO(dateOnly); // This creates a date without time/timezone
endDT = hotel.check_out ? DateTime.fromISO(hotel.check_out.split('T')[0]) : startDT;
} else {
// For timed events, use timezone conversion
startDT = DateTime.fromISO(hotel.check_in, {
zone: hotel.timezone ?? 'UTC'
});
endDT = hotel.check_out
? DateTime.fromISO(hotel.check_out, {
zone: hotel.timezone ?? 'UTC'
})
: startDT;
}
const startDateStr = startDT.toISODate();
const endDateStr = endDT.toISODate();
// Loop through lodging days only (excluding last day)
for (let i = 0; i < lodgingDays; i++) {
const currentDate = DateTime.fromJSDate(startDate).plus({ days: i });
const currentDateStr = currentDate.toISODate();
// Show lodging on days where check-in occurs through the day before check-out
// For lodging, we typically want to show it on the nights you're staying
if (currentDateStr >= startDateStr && currentDateStr < endDateStr) {
groupedLodging[currentDateStr]?.push(hotel);
}
} else if (groupedTransportations[transportationDate]) {
groupedTransportations[transportationDate].push(transportation);
}
}
});
return groupedTransportations;
return groupedLodging;
}
export function groupNotesByDate(
@ -292,6 +322,13 @@ export function groupChecklistsByDate(
return groupedChecklists;
}
// 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 continentCodeToString(code: string) {
switch (code) {
case 'AF':

View file

@ -244,7 +244,9 @@
"done": "Erledigt",
"loading_adventures": "Ladeabenteuer ...",
"name_location": "Name, Ort",
"collection_contents": "Sammelinhalt"
"collection_contents": "Sammelinhalt",
"check_in": "Einchecken",
"check_out": "Kasse"
},
"home": {
"desc_1": "Entdecken, planen und erkunden Sie mühelos",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Verbinden Sie Ihr Google Maps-Konto, um hochwertige Suchergebnisse und Empfehlungen für Standort zu erhalten."
},
"calendar": {
"all_categories": "Alle Kategorien",
"all_day_event": "Ganztägige Veranstaltung",
"calendar_overview": "Kalenderübersicht",
"categories": "Kategorien",
"day": "Tag",
"events_scheduled": "Veranstaltungen geplant",
"filter_by_category": "Filter nach Kategorie",
"filtered_results": "Gefilterte Ergebnisse",
"month": "Monat",
"today": "Heute",
"total_events": "Gesamtereignisse",
"week": "Woche"
}
}

View file

@ -98,6 +98,8 @@
"latitude": "Latitude",
"visit": "Visit",
"timed": "Timed",
"check_in": "Check In",
"check_out": "Check Out",
"coordinates": "Coordinates",
"copy_coordinates": "Copy Coordinates",
"visits": "Visits",
@ -691,5 +693,19 @@
"adventure_recommendations": "Adventure Recommendations",
"food": "Food",
"tourism": "Tourism"
},
"calendar": {
"today": "Today",
"month": "Month",
"week": "Week",
"day": "Day",
"events_scheduled": "events scheduled",
"total_events": "Total Events",
"all_categories": "All Categories",
"calendar_overview": "Calendar Overview",
"categories": "Categories",
"filtered_results": "Filtered Results",
"filter_by_category": "Filter by Category",
"all_day_event": "All Day Event"
}
}

View file

@ -296,7 +296,9 @@
"done": "Hecho",
"loading_adventures": "Cargando aventuras ...",
"name_location": "Nombre, ubicación",
"collection_contents": "Contenido de la colección"
"collection_contents": "Contenido de la colección",
"check_in": "Registrarse",
"check_out": "Verificar"
},
"worldtravel": {
"all": "Todo",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Conecte su cuenta de Google Maps para obtener resultados y recomendaciones de búsqueda de ubicación de alta calidad."
},
"calendar": {
"all_categories": "Todas las categorías",
"all_day_event": "Evento todo el día",
"calendar_overview": "Descripción general del calendario",
"categories": "Categorías",
"day": "Día",
"events_scheduled": "Eventos programados",
"filter_by_category": "Filtrar por categoría",
"filtered_results": "Resultados filtrados",
"month": "Mes",
"today": "Hoy",
"total_events": "Total de eventos",
"week": "Semana"
}
}

View file

@ -244,7 +244,9 @@
"done": "Fait",
"loading_adventures": "Chargement des aventures ...",
"name_location": "nom, emplacement",
"collection_contents": "Contenu de la collection"
"collection_contents": "Contenu de la collection",
"check_in": "Enregistrement",
"check_out": "Vérifier"
},
"home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Connectez votre compte Google Maps pour obtenir des résultats de recherche et recommandations de recherche de haute qualité."
},
"calendar": {
"all_categories": "Toutes les catégories",
"all_day_event": "Événement toute la journée",
"calendar_overview": "Aperçu du calendrier",
"categories": "Catégories",
"day": "Jour",
"events_scheduled": "événements prévus",
"filter_by_category": "Filtre par catégorie",
"filtered_results": "Résultats filtrés",
"month": "Mois",
"today": "Aujourd'hui",
"total_events": "Événements totaux",
"week": "Semaine"
}
}

View file

@ -244,7 +244,9 @@
"done": "Fatto",
"loading_adventures": "Caricamento di avventure ...",
"name_location": "Nome, posizione",
"collection_contents": "Contenuto di raccolta"
"collection_contents": "Contenuto di raccolta",
"check_in": "Check -in",
"check_out": "Guardare"
},
"home": {
"desc_1": "Scopri, pianifica ed esplora con facilità",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Collega il tuo account Google Maps per ottenere risultati e consigli di ricerca sulla posizione di alta qualità."
},
"calendar": {
"all_categories": "Tutte le categorie",
"all_day_event": "Evento per tutto il giorno",
"calendar_overview": "Panoramica del calendario",
"categories": "Categorie",
"day": "Giorno",
"events_scheduled": "eventi programmati",
"filter_by_category": "Filtro per categoria",
"filtered_results": "Risultati filtrati",
"month": "Mese",
"today": "Oggi",
"total_events": "Eventi totali",
"week": "Settimana"
}
}

View file

@ -244,7 +244,9 @@
"done": "완료",
"loading_adventures": "적재 모험 ...",
"name_location": "이름, 위치",
"collection_contents": "수집 내용"
"collection_contents": "수집 내용",
"check_in": "체크인",
"check_out": "체크 아웃"
},
"auth": {
"confirm_password": "비밀번호 확인",
@ -690,5 +692,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Google지도 계정을 연결하여 고품질 위치 검색 결과 및 권장 사항을 얻으십시오."
},
"calendar": {
"all_categories": "모든 카테고리",
"all_day_event": "하루 종일 이벤트",
"calendar_overview": "캘린더 개요",
"categories": "카테고리",
"day": "낮",
"events_scheduled": "예약 된 이벤트",
"filter_by_category": "카테고리 별 필터",
"filtered_results": "필터링 된 결과",
"month": "월",
"today": "오늘",
"total_events": "총 이벤트",
"week": "주"
}
}

View file

@ -244,7 +244,9 @@
"done": "Klaar",
"loading_adventures": "Adventuren laden ...",
"name_location": "naam, locatie",
"collection_contents": "Verzamelingsinhoud"
"collection_contents": "Verzamelingsinhoud",
"check_in": "Inchecken",
"check_out": "Uitchecken"
},
"home": {
"desc_1": "Ontdek, plan en verken met gemak",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Sluit uw Google Maps-account aan om zoekresultaten en aanbevelingen van hoge kwaliteit te krijgen."
},
"calendar": {
"all_categories": "Alle categorieën",
"all_day_event": "De hele dag evenement",
"calendar_overview": "Kalenderoverzicht",
"categories": "Categorieën",
"day": "Dag",
"events_scheduled": "geplande evenementen",
"filter_by_category": "Filter per categorie",
"filtered_results": "Gefilterde resultaten",
"month": "Maand",
"today": "Vandaag",
"total_events": "Totale gebeurtenissen",
"week": "Week"
}
}

View file

@ -296,7 +296,9 @@
"done": "Ferdig",
"loading_adventures": "Laster opp eventyr ...",
"name_location": "Navn, plassering",
"collection_contents": "Samlingsinnhold"
"collection_contents": "Samlingsinnhold",
"check_in": "Sjekk inn",
"check_out": "Sjekk ut"
},
"worldtravel": {
"country_list": "Liste over land",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Koble til Google Maps-kontoen din for å få søkeresultater og anbefalinger av høy kvalitet."
},
"calendar": {
"all_categories": "Alle kategorier",
"all_day_event": "Hele dagens arrangement",
"calendar_overview": "Kalenderoversikt",
"categories": "Kategorier",
"day": "Dag",
"events_scheduled": "hendelser planlagt",
"filter_by_category": "Filter etter kategori",
"filtered_results": "Filtrerte resultater",
"month": "Måned",
"today": "I dag",
"total_events": "Total hendelser",
"week": "Uke"
}
}

View file

@ -296,7 +296,9 @@
"loading_adventures": "Ładowanie przygód ...",
"name_location": "Nazwa, lokalizacja",
"delete_collection_warning": "Czy na pewno chcesz usunąć tę kolekcję? \nTego działania nie można cofnąć.",
"collection_contents": "Zawartość kolekcji"
"collection_contents": "Zawartość kolekcji",
"check_in": "Zameldować się",
"check_out": "Wymeldować się"
},
"worldtravel": {
"country_list": "Lista krajów",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Połącz swoje konto Google Maps, aby uzyskać wysokiej jakości wyniki wyszukiwania i zalecenia dotyczące lokalizacji."
},
"calendar": {
"all_categories": "Wszystkie kategorie",
"all_day_event": "Wydarzenie przez cały dzień",
"calendar_overview": "Przegląd kalendarza",
"categories": "Kategorie",
"day": "Dzień",
"events_scheduled": "Zaplanowane wydarzenia",
"filter_by_category": "Filtr według kategorii",
"filtered_results": "Przefiltrowane wyniki",
"month": "Miesiąc",
"today": "Dzisiaj",
"total_events": "Całkowite zdarzenia",
"week": "Tydzień"
}
}

View file

@ -296,7 +296,9 @@
"done": "Сделанный",
"loading_adventures": "Загрузка приключений ...",
"name_location": "имя, местоположение",
"collection_contents": "Содержание коллекции"
"collection_contents": "Содержание коллекции",
"check_in": "Регистрироваться",
"check_out": "Проверить"
},
"worldtravel": {
"country_list": "Список стран",
@ -691,5 +693,19 @@
"adventure_recommendations": "Рекомендации приключений",
"food": "Еда",
"tourism": "Туризм"
},
"calendar": {
"all_categories": "Все категории",
"all_day_event": "Событие на весь день",
"calendar_overview": "Обзор календаря",
"categories": "Категории",
"day": "День",
"events_scheduled": "События запланированы",
"filter_by_category": "Фильтр по категории",
"filtered_results": "Отфильтрованные результаты",
"month": "Месяц",
"today": "Сегодня",
"total_events": "Общее количество событий",
"week": "Неделя"
}
}

View file

@ -244,7 +244,9 @@
"done": "Gjort",
"loading_adventures": "Laddar äventyr ...",
"name_location": "namn, plats",
"collection_contents": "Insamlingsinnehåll"
"collection_contents": "Insamlingsinnehåll",
"check_in": "Checka in",
"check_out": "Checka ut"
},
"home": {
"desc_1": "Upptäck, planera och utforska med lätthet",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "Anslut ditt Google Maps-konto för att få sökresultat och rekommendationer av hög kvalitet."
},
"calendar": {
"all_categories": "Alla kategorier",
"all_day_event": "Hela dagen",
"calendar_overview": "Kalenderöversikt",
"categories": "Kategorier",
"day": "Dag",
"events_scheduled": "Händelser planerade",
"filter_by_category": "Filter efter kategori",
"filtered_results": "Filtrerade resultat",
"month": "Månad",
"today": "I dag",
"total_events": "Totala evenemang",
"week": "Vecka"
}
}

View file

@ -296,7 +296,9 @@
"done": "完毕",
"loading_adventures": "加载冒险...",
"name_location": "名称,位置",
"collection_contents": "收集内容"
"collection_contents": "收集内容",
"check_in": "报到",
"check_out": "查看"
},
"auth": {
"forgot_password": "忘记密码?",
@ -691,5 +693,19 @@
},
"google_maps": {
"google_maps_integration_desc": "连接您的Google Maps帐户以获取高质量的位置搜索结果和建议。"
},
"calendar": {
"all_categories": "所有类别",
"all_day_event": "全天活动",
"calendar_overview": "日历概述",
"categories": "类别",
"day": "天",
"events_scheduled": "预定事件",
"filter_by_category": "按类别过滤",
"filtered_results": "过滤结果",
"month": "月",
"today": "今天",
"total_events": "总事件",
"week": "星期"
}
}

View file

@ -1,5 +1,8 @@
import type { Adventure } from '$lib/types';
import type { PageServerLoad } from './$types';
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
import { isAllDay } from '$lib';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
@ -12,21 +15,114 @@ export const load = (async (event) => {
});
let adventures = (await visitedFetch.json()) as Adventure[];
// Get user's local timezone as fallback
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
let dates: Array<{
id: string;
start: string;
end: string;
title: string;
backgroundColor?: string;
extendedProps?: {
adventureName: string;
category: string;
icon: string;
timezone: string;
isAllDay: boolean;
formattedStart: string;
formattedEnd: string;
location?: string;
description?: string;
adventureId?: string;
};
}> = [];
adventures.forEach((adventure) => {
adventure.visits.forEach((visit) => {
if (visit.start_date) {
let startDate = visit.start_date;
let endDate = visit.end_date || visit.start_date;
const targetTimezone = visit.timezone || userTimezone;
const allDay = isAllDay(visit.start_date);
// Handle timezone conversion for non-all-day events
if (!allDay) {
// Convert UTC dates to target timezone
const startDateTime = new Date(visit.start_date);
const endDateTime = new Date(visit.end_date || visit.start_date);
// Format for calendar (ISO string in target timezone)
startDate = new Intl.DateTimeFormat('sv-SE', {
timeZone: targetTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
})
.format(startDateTime)
.replace(' ', 'T');
endDate = new Intl.DateTimeFormat('sv-SE', {
timeZone: targetTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
})
.format(endDateTime)
.replace(' ', 'T');
} else {
// For all-day events, use just the date part
startDate = visit.start_date.split('T')[0];
// For all-day events, add one day to end date to make it inclusive
const endDateObj = new Date(visit.end_date || visit.start_date);
endDateObj.setDate(endDateObj.getDate() + 1);
endDate = endDateObj.toISOString().split('T')[0];
}
// Create detailed title with timezone info
let detailedTitle = adventure.name;
if (adventure.category?.icon) {
detailedTitle = `${adventure.category.icon} ${detailedTitle}`;
}
// Add time info to title for non-all-day events
if (!allDay) {
const startTime = formatDateInTimezone(visit.start_date, targetTimezone);
detailedTitle += ` (${startTime.split(' ').slice(-2).join(' ')})`;
if (targetTimezone !== userTimezone) {
detailedTitle += ` ${targetTimezone}`;
}
}
dates.push({
id: adventure.id,
start: visit.start_date,
end: visit.end_date || visit.start_date,
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
start: startDate,
end: endDate,
title: detailedTitle,
backgroundColor: '#3b82f6',
extendedProps: {
adventureName: adventure.name,
category: adventure.category?.name || 'Adventure',
icon: adventure.category?.icon || '🗺️',
timezone: targetTimezone,
isAllDay: allDay,
formattedStart: allDay
? formatAllDayDate(visit.start_date)
: formatDateInTimezone(visit.start_date, targetTimezone),
formattedEnd: allDay
? formatAllDayDate(visit.end_date || visit.start_date)
: formatDateInTimezone(visit.end_date || visit.start_date, targetTimezone),
location: adventure.location || '',
description: adventure.description || '',
adventureId: adventure.id
}
});
}
});

View file

@ -1,38 +1,437 @@
<script lang="ts">
import type { PageData } from './$types';
// @ts-ignore
import Calendar from '@event-calendar/core';
// @ts-ignore
import TimeGrid from '@event-calendar/time-grid';
// @ts-ignore
import DayGrid from '@event-calendar/day-grid';
// @ts-ignore
import Interaction from '@event-calendar/interaction';
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import CalendarIcon from '~icons/mdi/calendar';
import DownloadIcon from '~icons/mdi/download';
import FilterIcon from '~icons/mdi/filter-variant';
import CloseIcon from '~icons/mdi/close';
import MapMarkerIcon from '~icons/mdi/map-marker';
import ClockIcon from '~icons/mdi/clock';
import SearchIcon from '~icons/mdi/magnify';
import ClearIcon from '~icons/mdi/close';
import { marked } from 'marked'; // Import the markdown parser
export let data: PageData;
const renderMarkdown = (markdown: string) => {
return marked(markdown);
};
let adventures = data.props.adventures;
let dates = data.props.dates;
let allDates = data.props.dates;
let filteredDates = [...allDates];
let icsCalendar = data.props.ics_calendar;
// turn the ics calendar into a data URL
let icsCalendarDataUrl = URL.createObjectURL(new Blob([icsCalendar], { type: 'text/calendar' }));
let plugins = [TimeGrid, DayGrid];
let options = {
// Modal state
let selectedEvent: any = null;
let showEventModal = false;
// Filter state
let showFilters = false;
let searchFilter = '';
let sidebarOpen = false;
// Get unique categories for filter
// Apply filters
$: {
filteredDates = allDates.filter((event) => {
const matchesSearch =
!searchFilter ||
event.extendedProps?.adventureName.toLowerCase().includes(searchFilter.toLowerCase()) ||
event.extendedProps?.location?.toLowerCase().includes(searchFilter.toLowerCase());
return matchesSearch;
});
}
let plugins = [TimeGrid, DayGrid, Interaction];
$: options = {
view: 'dayGridMonth',
events: [...dates]
events: filteredDates,
headerToolbar: {
start: 'prev,next today',
center: 'title',
end: 'dayGridMonth,timeGridWeek,timeGridDay'
},
buttonText: {
today: $t('calendar.today'),
dayGridMonth: $t('calendar.month'),
timeGridWeek: $t('calendar.week'),
timeGridDay: $t('calendar.day')
},
height: 'auto',
eventDisplay: 'block',
dayMaxEvents: 3,
moreLinkText: (num: number) => `+${num} more`,
eventClick: (info: any) => {
selectedEvent = info.event;
showEventModal = true;
},
eventMouseEnter: (info: any) => {
info.el.style.cursor = 'pointer';
},
themeSystem: 'standard'
};
console.log(dates);
function clearFilters() {
searchFilter = '';
}
function closeModal() {
showEventModal = false;
selectedEvent = null;
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
onMount(() => {
// Add custom CSS for calendar styling
const style = document.createElement('style');
style.textContent = `
.ec-toolbar {
background: hsl(var(--b2)) !important;
border-radius: 0.75rem !important;
padding: 1.25rem !important;
margin-bottom: 1.5rem !important;
border: 1px solid hsl(var(--b3)) !important;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1) !important;
}
.ec-button {
background: hsl(var(--b3)) !important;
border: 1px solid hsl(var(--b3)) !important;
color: hsl(var(--bc)) !important;
border-radius: 0.5rem !important;
padding: 0.5rem 1rem !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
}
.ec-button:hover {
background: hsl(var(--b1)) !important;
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15) !important;
}
.ec-button.ec-button-active {
background: hsl(var(--p)) !important;
color: hsl(var(--pc)) !important;
box-shadow: 0 4px 12px hsl(var(--p) / 0.3) !important;
}
.ec-day {
background: hsl(var(--b1)) !important;
border: 1px solid hsl(var(--b3)) !important;
transition: background-color 0.2s ease !important;
}
.ec-day:hover {
background: hsl(var(--b2)) !important;
}
.ec-day-today {
background: hsl(var(--b2)) !important;
position: relative !important;
}
.ec-day-today::before {
content: '' !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
height: 3px !important;
background: hsl(var(--p)) !important;
border-radius: 0.25rem !important;
}
.ec-event {
border-radius: 0.375rem !important;
padding: 0.25rem 0.5rem !important;
font-size: 0.75rem !important;
font-weight: 600 !important;
transition: all 0.2s ease !important;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1) !important;
}
.ec-event:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15) !important;
}
.ec-view {
background: hsl(var(--b1)) !important;
border-radius: 0.75rem !important;
overflow: hidden !important;
}
`;
document.head.appendChild(style);
});
</script>
<h1 class="text-center text-2xl font-bold">{$t('adventures.adventure_calendar')}</h1>
<svelte:head>
<title>{$t('adventures.adventure_calendar')} - AdventureLog</title>
</svelte:head>
<Calendar {plugins} {options} />
<div class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-base-200">
<div class="drawer lg:drawer-open">
<input id="calendar-drawer" type="checkbox" class="drawer-toggle" bind:checked={sidebarOpen} />
<!-- download calendar -->
<div class="flex items-center justify-center mt-4">
<a href={icsCalendarDataUrl} download="adventures.ics" class="btn btn-primary"
>{$t('adventures.download_calendar')}</a
>
<div class="drawer-content">
<!-- Header Section -->
<div class="sticky top-0 z-40 bg-base-100/80 backdrop-blur-lg border-b border-base-300">
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<button class="btn btn-ghost btn-square lg:hidden" on:click={toggleSidebar}>
<FilterIcon class="w-5 h-5" />
</button>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<CalendarIcon class="w-8 h-8 text-primary" />
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{$t('adventures.adventure_calendar')}
</h1>
<p class="text-sm text-base-content/60">
{filteredDates.length}
{$t('calendar.events_scheduled')}
</p>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="hidden md:flex items-center gap-2">
<div class="stats stats-horizontal bg-base-200/50 border border-base-300/50">
<div class="stat py-2 px-4">
<div class="stat-title text-xs">{$t('calendar.total_events')}</div>
<div class="stat-value text-lg text-primary">{allDates.length}</div>
</div>
<div class="stat py-2 px-4">
<div class="stat-title text-xs">{$t('navbar.adventures')}</div>
<div class="stat-value text-lg text-secondary">{adventures.length}</div>
</div>
</div>
</div>
</div>
<!-- Search Bar -->
<div class="mt-4 flex items-center gap-4">
<div class="relative flex-1 max-w-md">
<SearchIcon
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
/>
<input
type="text"
placeholder="Search adventures or locations..."
class="input input-bordered w-full pl-10 pr-10 bg-base-100/80"
bind:value={searchFilter}
/>
{#if searchFilter.length > 0}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content"
on:click={() => (searchFilter = '')}
>
<ClearIcon class="w-4 h-4" />
</button>
{/if}
</div>
</div>
<!-- Filter Chips -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/60"
>{$t('worldtravel.filter_by')}:</span
>
{#if searchFilter}
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
<ClearIcon class="w-3 h-3" />
{$t('worldtravel.clear_all')}
</button>
{/if}
</div>
</div>
</div>
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
<!-- Calendar -->
<div class="card bg-base-100 shadow-2xl border border-base-300/50">
<div class="card-body p-0">
<Calendar {plugins} {options} />
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="drawer-side z-50">
<label for="calendar-drawer" class="drawer-overlay"></label>
<div class="w-80 min-h-full bg-base-100 shadow-2xl">
<div class="p-6">
<!-- Sidebar Header -->
<div class="flex items-center gap-3 mb-8">
<div class="p-2 bg-primary/10 rounded-lg">
<FilterIcon class="w-6 h-6 text-primary" />
</div>
<h2 class="text-xl font-bold">{$t('adventures.filters_and_stats')}</h2>
</div>
<!-- Calendar Statistics -->
<div class="card bg-base-200/50 p-4 mb-6">
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<CalendarIcon class="w-5 h-5" />
{$t('calendar.calendar_overview')}
</h3>
<div class="space-y-4">
<div class="stat p-0">
<div class="stat-title text-sm">{$t('calendar.total_events')}</div>
<div class="stat-value text-2xl">{allDates.length}</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="stat p-0">
<div class="stat-title text-xs">{$t('navbar.adventures')}</div>
<div class="stat-value text-lg text-primary">{adventures.length}</div>
</div>
</div>
{#if filteredDates.length !== allDates.length}
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>{$t('calendar.filtered_results')}</span>
<span>{filteredDates.length} {$t('worldtravel.of')} {allDates.length}</span>
</div>
<progress
class="progress progress-primary w-full"
value={filteredDates.length}
max={allDates.length}
></progress>
</div>
{/if}
</div>
</div>
<!-- Quick Actions -->
<div class="space-y-3">
<a
href={icsCalendarDataUrl}
download="adventures.ics"
class="btn btn-primary w-full gap-2"
>
<DownloadIcon class="w-4 h-4" />
{$t('adventures.download_calendar')}
</a>
<button class="btn btn-ghost w-full gap-2" on:click={clearFilters}>
<ClearIcon class="w-4 h-4" />
{$t('worldtravel.clear_filters')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Event Details Modal -->
{#if showEventModal && selectedEvent}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal modal-open">
<div class="modal-box max-w-2xl bg-base-100 border border-base-300/50 shadow-2xl">
<div class="flex items-start justify-between mb-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<span class="text-2xl">{selectedEvent.extendedProps.icon}</span>
</div>
<div>
<h3 class="text-2xl font-bold">{selectedEvent.extendedProps.adventureName}</h3>
<div class="badge badge-primary badge-lg mt-2">
{selectedEvent.extendedProps.category}
</div>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-circle" on:click={closeModal}>
<CloseIcon class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<!-- Date & Time -->
<div class="card bg-base-200/50 border border-base-300/30">
<div class="card-body p-4">
<div class="flex items-center gap-3">
<ClockIcon class="w-6 h-6 text-primary flex-shrink-0" />
<div>
<div class="font-semibold text-lg">
{#if selectedEvent.extendedProps.isAllDay}
{$t('calendar.all_day_event')}
{:else}
{selectedEvent.extendedProps.formattedStart}
{#if selectedEvent.extendedProps.formattedEnd !== selectedEvent.extendedProps.formattedStart}
{selectedEvent.extendedProps.formattedEnd}
{/if}
{/if}
</div>
{#if !selectedEvent.extendedProps.isAllDay && selectedEvent.extendedProps.timezone}
<div class="text-sm text-base-content/70 mt-1">
{selectedEvent.extendedProps.timezone}
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Location -->
{#if selectedEvent.extendedProps.location}
<div class="card bg-base-200/50 border border-base-300/30">
<div class="card-body p-4">
<div class="flex items-center gap-3">
<MapMarkerIcon class="w-6 h-6 text-primary flex-shrink-0" />
<div class="font-semibold text-lg">{selectedEvent.extendedProps.location}</div>
</div>
</div>
</div>
{/if}
<!-- Description -->
{#if selectedEvent.extendedProps.description}
<div class="card bg-base-200/50 border border-base-300/30">
<div class="card-body p-4">
<div class="font-semibold text-lg mb-3">{$t('adventures.description')}</div>
<article
class="prose overflow-auto h-full max-w-full p-4 border border-base-300 rounded-lg mb-4 mt-4"
>
{@html renderMarkdown(selectedEvent.extendedProps.description || '')}
</article>
</div>
</div>
{/if}
{#if selectedEvent.extendedProps.adventureId}
<a
href={`/adventures/${selectedEvent.extendedProps.adventureId}`}
class="btn btn-neutral btn-block mt-4"
>
{$t('map.view_details')}
</a>
{/if}
</div>
<div class="modal-action mt-8">
<button class="btn btn-primary btn-lg" on:click={closeModal}> {$t('about.close')} </button>
</div>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-backdrop" on:click={closeModal}></div>
</div>
{/if}

View file

@ -17,12 +17,13 @@
import Plus from '~icons/mdi/plus';
import AdventureCard from '$lib/components/AdventureCard.svelte';
import AdventureLink from '$lib/components/AdventureLink.svelte';
import NotFound from '$lib/components/NotFound.svelte';
import { DefaultMarker, MapLibre, Marker, Popup, LineLayer, GeoJSON } from 'svelte-maplibre';
import { MapLibre, Marker, Popup, LineLayer, GeoJSON } from 'svelte-maplibre';
import TransportationCard from '$lib/components/TransportationCard.svelte';
import NoteCard from '$lib/components/NoteCard.svelte';
import NoteModal from '$lib/components/NoteModal.svelte';
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
import {
groupAdventuresByDate,
groupNotesByDate,
@ -31,8 +32,11 @@
osmTagToEmoji,
groupLodgingByDate,
LODGING_TYPES_ICONS,
getBasemapUrl
getBasemapUrl,
isAllDay
} from '$lib';
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import AdventureModal from '$lib/components/AdventureModal.svelte';
@ -60,19 +64,6 @@
let collection: Collection;
// add christmas and new years
// dates = Array.from({ length: 25 }, (_, i) => {
// const date = new Date();
// date.setMonth(11);
// date.setDate(i + 1);
// return {
// id: i.toString(),
// start: date.toISOString(),
// end: date.toISOString(),
// title: '🎄'
// };
// });
let dates: Array<{
id: string;
start: string;
@ -93,16 +84,79 @@
dates = [];
if (adventures) {
dates = dates.concat(
adventures.flatMap((adventure) =>
adventure.visits.map((visit) => ({
id: adventure.id,
start: visit.start_date || '', // Ensure it's a string
end: visit.end_date || visit.start_date || '', // Ensure it's a string
title: adventure.name + (adventure.category?.icon ? ' ' + adventure.category.icon : '')
}))
)
);
adventures.forEach((adventure) => {
adventure.visits.forEach((visit) => {
if (visit.start_date) {
let startDate = visit.start_date;
let endDate = visit.end_date || visit.start_date;
const targetTimezone = visit.timezone || userTimezone;
const allDay = isAllDay(visit.start_date);
// Handle timezone conversion for non-all-day events
if (!allDay) {
// Convert UTC dates to target timezone
const startDateTime = new Date(visit.start_date);
const endDateTime = new Date(visit.end_date || visit.start_date);
// Format for calendar (ISO string in target timezone)
startDate = new Intl.DateTimeFormat('sv-SE', {
timeZone: targetTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
})
.format(startDateTime)
.replace(' ', 'T');
endDate = new Intl.DateTimeFormat('sv-SE', {
timeZone: targetTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
})
.format(endDateTime)
.replace(' ', 'T');
} else {
// For all-day events, use just the date part
startDate = visit.start_date.split('T')[0];
// For all-day events, add one day to end date to make it inclusive
const endDateObj = new Date(visit.end_date || visit.start_date);
endDateObj.setDate(endDateObj.getDate() + 1);
endDate = endDateObj.toISOString().split('T')[0];
}
// Create detailed title with timezone info
let detailedTitle = adventure.name;
if (adventure.category?.icon) {
detailedTitle = `${adventure.category.icon} ${detailedTitle}`;
}
// Add time info to title for non-all-day events
if (!allDay) {
const startTime = formatDateInTimezone(visit.start_date, targetTimezone);
detailedTitle += ` (${startTime.split(' ').slice(-2).join(' ')})`;
if (targetTimezone !== userTimezone) {
detailedTitle += ` ${targetTimezone}`;
}
}
dates.push({
id: adventure.id,
start: startDate,
end: endDate,
title: detailedTitle,
backgroundColor: '#3b82f6'
});
}
});
});
}
if (transportations) {
@ -113,7 +167,8 @@
id: transportation.id,
start: transportation.date || '', // Ensure it's a string
end: transportation.end_date || transportation.date || '', // Ensure it's a string
title: transportation.name + (transportation.type ? ` (${transportation.type})` : '')
title: transportation.name + (transportation.type ? ` (${transportation.type})` : ''),
backgroundColor: '#10b981'
}))
);
}
@ -122,12 +177,61 @@
dates = dates.concat(
lodging
.filter((i) => i.check_in)
.map((lodging) => ({
id: lodging.id,
start: lodging.check_in || '', // Ensure it's a string
end: lodging.check_out || lodging.check_in || '', // Ensure it's a string
title: lodging.name
}))
.map((lodging) => {
const checkIn = lodging.check_in;
const checkOut = lodging.check_out || lodging.check_in;
if (!checkIn) return null;
const isAlldayLodging: boolean = isAllDay(checkIn as string);
let startDate: string;
let endDate: string;
if (isAlldayLodging) {
// For all-day, use date part only, no timezone conversion
startDate = (checkIn as string).split('T')[0];
const endDateObj = new Date(checkOut as string);
endDateObj.setDate(endDateObj.getDate());
endDate = endDateObj.toISOString().split('T')[0];
return {
id: lodging.id,
start: startDate,
end: endDate,
title: `${getLodgingIcon(lodging.type)} ${lodging.name}`,
backgroundColor: '#f59e0b'
};
} else {
// Only use timezone if not all-day
const lodgingTimezone = lodging.timezone || userTimezone;
const checkInDateTime = new Date(checkIn as string);
const checkOutDateTime = new Date(checkOut as string);
startDate = new Intl.DateTimeFormat('sv-SE', {
timeZone: lodgingTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(checkInDateTime);
endDate = new Intl.DateTimeFormat('sv-SE', {
timeZone: lodgingTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(checkOutDateTime);
return {
id: lodging.id,
start: startDate,
end: endDate,
title: lodging.name,
backgroundColor: '#f59e0b'
};
}
})
.filter((item) => item !== null)
);
}
@ -140,11 +244,6 @@
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