Files
AdventureLog/frontend/src/lib/dateUtils.ts
Sean Morley 96dfda1cfb Date Fixes, Translations, Misc Bugs (#840)
* Translated using Weblate (Spanish)

Currently translated at 100.0% (956 of 956 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

* Added translation using Weblate (English (United States))

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (956 of 956 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/nb_NO/

* Remove empty English (United States) locale file

* Translated using Weblate (Spanish)

Currently translated at 100.0% (956 of 956 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

* [BUG]Ordered Itinerary includes visits that are outside itinerary date range
Fixes #746

* [BUG] Server Error (500) when trying to access the API docs
Fixes #712

* [BUG] Single day Collections will think location visits are out of date range
Fixes #827

* Fixes #654

* Translated using Weblate (Spanish)

Currently translated at 100.0% (956 of 956 strings)

Translation: AdventureLog/Web App
Translate-URL: https://hosted.weblate.org/projects/adventurelog/web-app/es/

* Added Slovak translations (#815)

* Created sk.json

* Update Navbar.svelte

* Update +layout.svelte

---------

Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

* Implement code changes to enhance functionality and improve performance

---------

Co-authored-by: Nikolai Eidsheim <nikolai.eidsheim@gmail.com>
Co-authored-by: Sergio <garcia.sergio@me.com>
Co-authored-by: fantastron27 <fantastron27@gmail.com>
2025-09-06 21:45:40 -04:00

690 lines
17 KiB
TypeScript

// @ts-ignore
import { DateTime } from 'luxon';
import type { Checklist, Collection, Lodging, Note, Transportation, Visit } from './types';
import { isAllDay } from '$lib';
/**
* Convert a UTC ISO date to a datetime-local value in the specified timezone
*/
export function toLocalDatetime(
utcDate: string | null,
timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone
): string {
if (!utcDate) return '';
const dt = DateTime.fromISO(utcDate, { zone: 'UTC' });
if (!dt.isValid) return '';
const isoString = dt.setZone(timezone).toISO({
suppressSeconds: true,
includeOffset: false
});
return isoString ? isoString.slice(0, 16) : '';
}
/**
* Convert a local datetime to UTC
*/
export function toUTCDatetime(
localDate: string,
timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
allDay: boolean = false
): string | null {
if (!localDate) return null;
if (allDay) {
// Treat as date only, set UTC midnight
return DateTime.fromISO(localDate, { zone: 'UTC' })
.startOf('day')
.toISO({ suppressMilliseconds: true });
}
return DateTime.fromISO(localDate, { zone: timezone })
.toUTC()
.toISO({ suppressMilliseconds: true });
}
/**
* Updates local datetime values based on UTC date and timezone
*/
export function updateLocalDate({
utcDate,
timezone
}: {
utcDate: string | null;
timezone: string;
}) {
return { localDate: toLocalDatetime(utcDate, timezone) };
}
/**
* Updates UTC datetime values based on local datetime and timezone
*/
export function updateUTCDate({
localDate,
timezone,
allDay = false
}: {
localDate: string;
timezone: string;
allDay?: boolean;
}) {
return { utcDate: toUTCDatetime(localDate, timezone, allDay) };
}
/**
* Validate date ranges using UTC comparison
*/
export function validateDateRange(
startDate: string,
endDate: string
): { valid: boolean; error?: string } {
if (endDate && !startDate) {
return { valid: false, error: 'Start date is required when end date is provided' };
}
if (
startDate &&
endDate &&
DateTime.fromISO(startDate, { zone: 'utc' }).toMillis() >
DateTime.fromISO(endDate, { zone: 'utc' }).toMillis()
) {
return { valid: false, error: 'Start date must be before end date (based on UTC)' };
}
return { valid: true };
}
export function formatDateInTimezone(utcDate: string, timezone: string | null): string {
if (!utcDate) return '';
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone || undefined,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).format(new Date(utcDate));
} catch {
return new Date(utcDate).toLocaleString();
}
}
export function formatUTCDate(utcDate: string | null): string {
if (!utcDate) return '';
const dateTime = DateTime.fromISO(utcDate);
if (!dateTime.isValid) return '';
return dateTime.toISO()?.slice(0, 16).replace('T', ' ') || '';
}
export function formatAllDayDate(dateString: string): string {
if (!dateString) return '';
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));
}
// ==== FIXED TIMEZONE-AWARE DATE RANGE LOGIC ====
/**
* Extracts start and end dates from various entity types (Luxon DateTime)
* Returns also isAllDay flag for correct comparison logic
*/
function getEntityDateRange(entity: Visit | Transportation | Lodging | Note | Checklist): {
start: DateTime | null;
end: DateTime | null;
isAllDay: boolean;
} {
let start: DateTime | null = null;
let end: DateTime | null = null;
let isAllDayEvent = false;
try {
let timezone = (entity as Visit).timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
if ('start_date' in entity && 'end_date' in entity) {
// Check if all-day (no time portion)
isAllDayEvent = isAllDay(entity.start_date) && isAllDay(entity.end_date);
console;
if (isAllDayEvent) {
start = entity.start_date
? DateTime.fromISO(entity.start_date.split('T')[0], { zone: 'UTC' }).startOf('day')
: null;
end = entity.end_date
? DateTime.fromISO(entity.end_date.split('T')[0], { zone: 'UTC' }).endOf('day')
: null;
} else {
start = DateTime.fromISO(entity.start_date, { zone: 'UTC' }).setZone(timezone);
end = DateTime.fromISO(entity.end_date, { zone: 'UTC' }).setZone(timezone);
}
} else if ('date' in entity && 'end_date' in entity) {
isAllDayEvent = !!(entity.date && entity.date.length === 10);
if (isAllDayEvent) {
start = DateTime.fromISO(entity.date, { zone: 'UTC' }).startOf('day');
end = DateTime.fromISO(entity.end_date, { zone: 'UTC' }).endOf('day');
} else {
start = DateTime.fromISO(entity.date, { zone: 'UTC' }).setZone(timezone);
end = DateTime.fromISO(entity.end_date, { zone: 'UTC' }).setZone(timezone);
}
} else if ('check_in' in entity && 'check_out' in entity) {
isAllDayEvent = !!(entity.check_in && entity.check_in.length === 10);
if (isAllDayEvent) {
start = DateTime.fromISO(entity.check_in, { zone: 'UTC' }).startOf('day');
end = DateTime.fromISO(entity.check_out, { zone: 'UTC' }).endOf('day');
} else {
start = DateTime.fromISO(entity.check_in, { zone: 'UTC' }).setZone(timezone);
end = DateTime.fromISO(entity.check_out, { zone: 'UTC' }).setZone(timezone);
}
} else if ('date' in entity) {
isAllDayEvent = !!(entity.date && entity.date.length === 10);
if (isAllDayEvent) {
start = DateTime.fromISO(entity.date, { zone: 'UTC' }).startOf('day');
end = start;
} else {
start = DateTime.fromISO(entity.date, { zone: 'UTC' }).setZone(timezone);
end = start;
}
}
} catch (error) {
console.error('Error processing entity dates:', error);
}
return { start, end, isAllDay: isAllDayEvent };
}
/**
* Extract collection start/end as Luxon DateTime w/allDay logic
*/
function getCollectionDateRange(collection: Collection): {
start: DateTime | null;
end: DateTime | null;
isAllDay: boolean;
} {
if (!collection.start_date || !collection.end_date) {
return { start: null, end: null, isAllDay: false };
}
// Assume collection always uses full datetimes in ISO string
const isAllDay = collection.start_date.length === 10 && collection.end_date.length === 10;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const start = isAllDay
? DateTime.fromISO(collection.start_date, { zone: 'UTC' }).startOf('day')
: DateTime.fromISO(collection.start_date, { zone: 'UTC' }).setZone(timezone);
const end = isAllDay
? DateTime.fromISO(collection.end_date, { zone: 'UTC' }).endOf('day')
: DateTime.fromISO(collection.end_date, { zone: 'UTC' }).setZone(timezone);
return { start, end, isAllDay };
}
/**
* Checks if an entity falls within a collection's date range (timezone-safe, all-day-aware)
*/
export function isEntityInCollectionDateRange(
entity: Visit | Transportation | Lodging | Note | Checklist,
collection: Collection
): boolean {
if (!collection?.start_date || !collection.end_date) {
return false;
}
const { start: entityStart, end: entityEnd, isAllDay: entityAllDay } = getEntityDateRange(entity);
const {
start: collStart,
end: collEnd,
isAllDay: collAllDay
} = getCollectionDateRange(collection);
// If any dates are missing, don't match
if (!entityStart || !collStart) return false;
// If either side is all-day, use date comparison
if (entityAllDay || collAllDay) {
// Compare only date components
const entStartDate = entityStart.startOf('day');
const entEndDate = (entityEnd || entityStart).endOf('day');
const colStartDate = collStart.startOf('day');
const colEndDate = collEnd.endOf('day');
return entStartDate <= colEndDate && entEndDate >= colStartDate;
} else {
// Compare actual DateTimes
const entEnd = entityEnd || entityStart;
return entityStart <= collEnd && entEnd >= collStart;
}
}
export function isEntityOutsideCollectionDateRange(
entity: Visit | Transportation | Lodging | Note | Checklist,
collection: Collection
): boolean {
return !isEntityInCollectionDateRange(entity, collection);
}
export function getEntitiesInDateRange<
T extends Visit | Transportation | Lodging | Note | Checklist
>(entities: T[], collection: Collection): T[] {
return entities.filter((entity) => isEntityInCollectionDateRange(entity, collection));
}
export function getEntitiesOutsideDateRange<
T extends Visit | Transportation | Lodging | Note | Checklist
>(entities: T[], collection: Collection): T[] {
return entities.filter((entity) => isEntityOutsideCollectionDateRange(entity, collection));
}
export const VALID_TIMEZONES = [
'Africa/Abidjan',
'Africa/Accra',
'Africa/Addis_Ababa',
'Africa/Algiers',
'Africa/Asmera',
'Africa/Bamako',
'Africa/Bangui',
'Africa/Banjul',
'Africa/Bissau',
'Africa/Blantyre',
'Africa/Brazzaville',
'Africa/Bujumbura',
'Africa/Cairo',
'Africa/Casablanca',
'Africa/Ceuta',
'Africa/Conakry',
'Africa/Dakar',
'Africa/Dar_es_Salaam',
'Africa/Djibouti',
'Africa/Douala',
'Africa/El_Aaiun',
'Africa/Freetown',
'Africa/Gaborone',
'Africa/Harare',
'Africa/Johannesburg',
'Africa/Juba',
'Africa/Kampala',
'Africa/Khartoum',
'Africa/Kigali',
'Africa/Kinshasa',
'Africa/Lagos',
'Africa/Libreville',
'Africa/Lome',
'Africa/Luanda',
'Africa/Lubumbashi',
'Africa/Lusaka',
'Africa/Malabo',
'Africa/Maputo',
'Africa/Maseru',
'Africa/Mbabane',
'Africa/Mogadishu',
'Africa/Monrovia',
'Africa/Nairobi',
'Africa/Ndjamena',
'Africa/Niamey',
'Africa/Nouakchott',
'Africa/Ouagadougou',
'Africa/Porto-Novo',
'Africa/Sao_Tome',
'Africa/Tripoli',
'Africa/Tunis',
'Africa/Windhoek',
'America/Adak',
'America/Anchorage',
'America/Anguilla',
'America/Antigua',
'America/Araguaina',
'America/Argentina/La_Rioja',
'America/Argentina/Rio_Gallegos',
'America/Argentina/Salta',
'America/Argentina/San_Juan',
'America/Argentina/San_Luis',
'America/Argentina/Tucuman',
'America/Argentina/Ushuaia',
'America/Aruba',
'America/Asuncion',
'America/Bahia',
'America/Bahia_Banderas',
'America/Barbados',
'America/Belem',
'America/Belize',
'America/Blanc-Sablon',
'America/Boa_Vista',
'America/Bogota',
'America/Boise',
'America/Buenos_Aires',
'America/Cambridge_Bay',
'America/Campo_Grande',
'America/Cancun',
'America/Caracas',
'America/Catamarca',
'America/Cayenne',
'America/Cayman',
'America/Chicago',
'America/Chihuahua',
'America/Ciudad_Juarez',
'America/Coral_Harbour',
'America/Cordoba',
'America/Costa_Rica',
'America/Creston',
'America/Cuiaba',
'America/Curacao',
'America/Danmarkshavn',
'America/Dawson',
'America/Dawson_Creek',
'America/Denver',
'America/Detroit',
'America/Dominica',
'America/Edmonton',
'America/Eirunepe',
'America/El_Salvador',
'America/Fort_Nelson',
'America/Fortaleza',
'America/Glace_Bay',
'America/Godthab',
'America/Goose_Bay',
'America/Grand_Turk',
'America/Grenada',
'America/Guadeloupe',
'America/Guatemala',
'America/Guayaquil',
'America/Guyana',
'America/Halifax',
'America/Havana',
'America/Hermosillo',
'America/Indiana/Knox',
'America/Indiana/Marengo',
'America/Indiana/Petersburg',
'America/Indiana/Tell_City',
'America/Indiana/Vevay',
'America/Indiana/Vincennes',
'America/Indiana/Winamac',
'America/Indianapolis',
'America/Inuvik',
'America/Iqaluit',
'America/Jamaica',
'America/Jujuy',
'America/Juneau',
'America/Kentucky/Monticello',
'America/Kralendijk',
'America/La_Paz',
'America/Lima',
'America/Los_Angeles',
'America/Louisville',
'America/Lower_Princes',
'America/Maceio',
'America/Managua',
'America/Manaus',
'America/Marigot',
'America/Martinique',
'America/Matamoros',
'America/Mazatlan',
'America/Mendoza',
'America/Menominee',
'America/Merida',
'America/Metlakatla',
'America/Mexico_City',
'America/Miquelon',
'America/Moncton',
'America/Monterrey',
'America/Montevideo',
'America/Montserrat',
'America/Nassau',
'America/New_York',
'America/Nome',
'America/Noronha',
'America/North_Dakota/Beulah',
'America/North_Dakota/Center',
'America/North_Dakota/New_Salem',
'America/Ojinaga',
'America/Panama',
'America/Paramaribo',
'America/Phoenix',
'America/Port-au-Prince',
'America/Port_of_Spain',
'America/Porto_Velho',
'America/Puerto_Rico',
'America/Punta_Arenas',
'America/Rankin_Inlet',
'America/Recife',
'America/Regina',
'America/Resolute',
'America/Rio_Branco',
'America/Santarem',
'America/Santiago',
'America/Santo_Domingo',
'America/Sao_Paulo',
'America/Scoresbysund',
'America/Sitka',
'America/St_Barthelemy',
'America/St_Johns',
'America/St_Kitts',
'America/St_Lucia',
'America/St_Thomas',
'America/St_Vincent',
'America/Swift_Current',
'America/Tegucigalpa',
'America/Thule',
'America/Tijuana',
'America/Toronto',
'America/Tortola',
'America/Vancouver',
'America/Whitehorse',
'America/Winnipeg',
'America/Yakutat',
'Antarctica/Casey',
'Antarctica/Davis',
'Antarctica/DumontDUrville',
'Antarctica/Macquarie',
'Antarctica/Mawson',
'Antarctica/McMurdo',
'Antarctica/Palmer',
'Antarctica/Rothera',
'Antarctica/Syowa',
'Antarctica/Troll',
'Antarctica/Vostok',
'Arctic/Longyearbyen',
'Asia/Aden',
'Asia/Almaty',
'Asia/Amman',
'Asia/Anadyr',
'Asia/Aqtau',
'Asia/Aqtobe',
'Asia/Ashgabat',
'Asia/Atyrau',
'Asia/Baghdad',
'Asia/Bahrain',
'Asia/Baku',
'Asia/Bangkok',
'Asia/Barnaul',
'Asia/Beirut',
'Asia/Bishkek',
'Asia/Brunei',
'Asia/Calcutta',
'Asia/Chita',
'Asia/Colombo',
'Asia/Damascus',
'Asia/Dhaka',
'Asia/Dili',
'Asia/Dubai',
'Asia/Dushanbe',
'Asia/Famagusta',
'Asia/Gaza',
'Asia/Hebron',
'Asia/Hong_Kong',
'Asia/Hovd',
'Asia/Irkutsk',
'Asia/Jakarta',
'Asia/Jayapura',
'Asia/Jerusalem',
'Asia/Kabul',
'Asia/Kamchatka',
'Asia/Karachi',
'Asia/Katmandu',
'Asia/Khandyga',
'Asia/Krasnoyarsk',
'Asia/Kuala_Lumpur',
'Asia/Kuching',
'Asia/Kuwait',
'Asia/Macau',
'Asia/Magadan',
'Asia/Makassar',
'Asia/Manila',
'Asia/Muscat',
'Asia/Nicosia',
'Asia/Novokuznetsk',
'Asia/Novosibirsk',
'Asia/Omsk',
'Asia/Oral',
'Asia/Phnom_Penh',
'Asia/Pontianak',
'Asia/Pyongyang',
'Asia/Qatar',
'Asia/Qostanay',
'Asia/Qyzylorda',
'Asia/Rangoon',
'Asia/Riyadh',
'Asia/Saigon',
'Asia/Sakhalin',
'Asia/Samarkand',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Srednekolymsk',
'Asia/Taipei',
'Asia/Tashkent',
'Asia/Tbilisi',
'Asia/Tehran',
'Asia/Thimphu',
'Asia/Tokyo',
'Asia/Tomsk',
'Asia/Ulaanbaatar',
'Asia/Urumqi',
'Asia/Ust-Nera',
'Asia/Vientiane',
'Asia/Vladivostok',
'Asia/Yakutsk',
'Asia/Yekaterinburg',
'Asia/Yerevan',
'Atlantic/Azores',
'Atlantic/Bermuda',
'Atlantic/Canary',
'Atlantic/Cape_Verde',
'Atlantic/Faeroe',
'Atlantic/Madeira',
'Atlantic/Reykjavik',
'Atlantic/South_Georgia',
'Atlantic/St_Helena',
'Atlantic/Stanley',
'Australia/Adelaide',
'Australia/Brisbane',
'Australia/Broken_Hill',
'Australia/Darwin',
'Australia/Eucla',
'Australia/Hobart',
'Australia/Lindeman',
'Australia/Lord_Howe',
'Australia/Melbourne',
'Australia/Perth',
'Australia/Sydney',
'Europe/Amsterdam',
'Europe/Andorra',
'Europe/Astrakhan',
'Europe/Athens',
'Europe/Belgrade',
'Europe/Berlin',
'Europe/Bratislava',
'Europe/Brussels',
'Europe/Bucharest',
'Europe/Budapest',
'Europe/Busingen',
'Europe/Chisinau',
'Europe/Copenhagen',
'Europe/Dublin',
'Europe/Gibraltar',
'Europe/Guernsey',
'Europe/Helsinki',
'Europe/Isle_of_Man',
'Europe/Istanbul',
'Europe/Jersey',
'Europe/Kaliningrad',
'Europe/Kiev',
'Europe/Kirov',
'Europe/Lisbon',
'Europe/Ljubljana',
'Europe/London',
'Europe/Luxembourg',
'Europe/Madrid',
'Europe/Malta',
'Europe/Mariehamn',
'Europe/Minsk',
'Europe/Monaco',
'Europe/Moscow',
'Europe/Oslo',
'Europe/Paris',
'Europe/Podgorica',
'Europe/Prague',
'Europe/Riga',
'Europe/Rome',
'Europe/Samara',
'Europe/San_Marino',
'Europe/Sarajevo',
'Europe/Saratov',
'Europe/Simferopol',
'Europe/Skopje',
'Europe/Sofia',
'Europe/Stockholm',
'Europe/Tallinn',
'Europe/Tirane',
'Europe/Ulyanovsk',
'Europe/Vaduz',
'Europe/Vatican',
'Europe/Vienna',
'Europe/Vilnius',
'Europe/Volgograd',
'Europe/Warsaw',
'Europe/Zagreb',
'Europe/Zurich',
'Indian/Antananarivo',
'Indian/Chagos',
'Indian/Christmas',
'Indian/Cocos',
'Indian/Comoro',
'Indian/Kerguelen',
'Indian/Mahe',
'Indian/Maldives',
'Indian/Mauritius',
'Indian/Mayotte',
'Indian/Reunion',
'Pacific/Apia',
'Pacific/Auckland',
'Pacific/Bougainville',
'Pacific/Chatham',
'Pacific/Easter',
'Pacific/Efate',
'Pacific/Enderbury',
'Pacific/Fakaofo',
'Pacific/Fiji',
'Pacific/Funafuti',
'Pacific/Galapagos',
'Pacific/Gambier',
'Pacific/Guadalcanal',
'Pacific/Guam',
'Pacific/Honolulu',
'Pacific/Kiritimati',
'Pacific/Kosrae',
'Pacific/Kwajalein',
'Pacific/Majuro',
'Pacific/Marquesas',
'Pacific/Midway',
'Pacific/Nauru',
'Pacific/Niue',
'Pacific/Norfolk',
'Pacific/Noumea',
'Pacific/Pago_Pago',
'Pacific/Palau',
'Pacific/Pitcairn',
'Pacific/Ponape',
'Pacific/Port_Moresby',
'Pacific/Rarotonga',
'Pacific/Saipan',
'Pacific/Tahiti',
'Pacific/Tarawa',
'Pacific/Tongatapu',
'Pacific/Truk',
'Pacific/Wake',
'Pacific/Wallis'
];