diff --git a/src/src/app/components/trip/csv.ts b/src/src/app/components/trip/csv.ts new file mode 100644 index 0000000..f896b01 --- /dev/null +++ b/src/src/app/components/trip/csv.ts @@ -0,0 +1,93 @@ +import { FlattenedTripItem } from '../../types/trip'; +import { UtilsService } from '../../services/utils.service'; + +export function generateTripICSFile( + tripItems: FlattenedTripItem[], + tripName: string = 'Trip Calendar', + utilsService: UtilsService, +): void { + const now = new Date(); + const tsz = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + + let icsContent = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Trip//Trip Calendar//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + `X-WR-CALNAME:${tripName}`, + 'X-WR-TIMEZONE:Europe/Paris', + ].join('\r\n'); + + if (tripItems.some((i) => !i.td_date)) + utilsService.toast('warn', 'Caution', 'You have date-less days, they will not be included in your export'); + + tripItems.forEach((item, index) => { + if (!item.td_date) return; + + const eventDate = item.td_date; + const eventTime = item.time ?? '00:00'; + const [year, month, day] = eventDate.split('-'); + const [hours, minutes] = eventTime.split(':'); + const dtStart = `${year}${month}${day}T${hours.padStart(2, '0')}${minutes.padStart(2, '0')}00`; + + const startDateTime = new Date(`${eventDate}T${eventTime}`); + const nextItemSameDay = tripItems.slice(index + 1).find((i) => i.td_date === item.td_date && i.time); + + const endDateTime = nextItemSameDay?.time + ? new Date(`${nextItemSameDay.td_date}T${nextItemSameDay.time}`) + : new Date(startDateTime.getTime() + 60 * 60 * 1000); + const dtEnd = endDateTime.toISOString().replace(/[-:]/g, '').split('.')[0]; + + const eventDescription: string[] = []; + if (item.comment) eventDescription.push(`Comment: ${item.comment}`); + if (item.place?.name) eventDescription.push(`Place: ${item.place.name}`); + + const lat = item.lat ?? item.place?.lat; + const lng = item.lng ?? item.place?.lng; + if (lat && lng) { + eventDescription.push(`Coordinates: ${lat}, ${lng}`); + eventDescription.push(`GMaps: https://www.google.com/maps?q=${lat},${lng}`); + } + + if (item.price) eventDescription.push(`Price: ${item.price}€`); + + const description = eventDescription.join('\\n').replace(/\n/g, '\\n'); + const location = item.place?.name ?? (lat && lng ? `${lat}, ${lng}` : ''); + const geo = lat && lng ? `GEO:${lat};${lng}` : ''; + const uid = `Trip-${tripName.replace(/[^a-zA-Z0-9-_]/g, '_')}-item-${item.id}-${tsz}`; + + icsContent += + '\r\n' + + [ + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:${tsz}`, + `DTSTART:${dtStart}`, + `DTEND:${dtEnd}`, + `SUMMARY:${escapeICSText(item.text)}`, + description ? `DESCRIPTION:${escapeICSText(description)}` : '', + location ? `LOCATION:${escapeICSText(location)}` : '', + geo, + item.status ? `STATUS:${item.status.label.toUpperCase()}` : '', + 'END:VEVENT', + ] + .filter((line) => line) + .join('\r\n'); + }); + + icsContent += '\r\n' + 'END:VCALENDAR'; + const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${tripName.replace(/\s+/g, '_')}.ics`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); +} + +function escapeICSText(text: string): string { + if (!text) return ''; + return text.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n').replace(/\r/g, ''); +} diff --git a/src/src/app/components/trip/ics.ts b/src/src/app/components/trip/ics.ts new file mode 100644 index 0000000..f985d0c --- /dev/null +++ b/src/src/app/components/trip/ics.ts @@ -0,0 +1,98 @@ +import { FlattenedTripItem } from '../../types/trip'; +import { UtilsService } from '../../services/utils.service'; + +export function generateTripICSFile( + tripItems: FlattenedTripItem[], + tripName: string = 'Trip Calendar', + utilsService: UtilsService, +): void { + const now = new Date(); + const tsz = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + + let icsContent = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Trip//Trip Calendar//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + `X-WR-CALNAME:${tripName}`, + 'X-WR-TIMEZONE:Europe/Paris', + ].join('\r\n'); + + if (tripItems.some((i) => !i.td_date)) + utilsService.toast('warn', 'Caution', 'You have date-less days, they will not be included in your export'); + + tripItems.forEach((item, index) => { + if (!item.td_date) return; + + const eventDate = item.td_date; + const eventTime = item.time ?? '00:00'; + const [year, month, day] = eventDate.split('-'); + const [hours, minutes] = eventTime.split(':'); + const dtStart = `${year}${month}${day}T${hours.padStart(2, '0')}${minutes.padStart(2, '0')}00`; + + const startDateTime = new Date(`${eventDate}T${eventTime}`); + + const nextItem = tripItems.slice(index + 1).find((i) => i.td_date); + const nextItemSameDay = tripItems.slice(index + 1).find((i) => i.td_date === item.td_date && i.time); + + const endDateTime = nextItemSameDay?.time + ? new Date(`${nextItemSameDay.td_date}T${nextItemSameDay.time}`) + : new Date(startDateTime.getTime() + 60 * 60 * 1000); + const dtEnd = endDateTime.toISOString().replace(/[-:]/g, '').split('.')[0]; + + // Build description + const eventDescription: string[] = []; + + if (item.comment) eventDescription.push(`Comment: ${item.comment}`); + + if (item.place?.name) eventDescription.push(`Place: ${item.place.name}`); + + const lat = item.lat ?? item.place?.lat; + const lng = item.lng ?? item.place?.lng; + if (lat && lng) { + eventDescription.push(`Coordinates: ${lat}, ${lng}`); + eventDescription.push(`GMaps: https://www.google.com/maps?q=${lat},${lng}`); + } + + if (item.price) eventDescription.push(`Price: ${item.price}€`); + + const description = eventDescription.join('\\n').replace(/\n/g, '\\n'); + const location = item.place?.name ?? (lat && lng ? `${lat}, ${lng}` : ''); + const geo = lat && lng ? `GEO:${lat};${lng}` : ''; + const uid = `Trip-${tripName.replace(/[^a-zA-Z0-9-_]/g, '_')}-item-${item.id}-${tsz}`; + + icsContent += + '\r\n' + + [ + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:${tsz}`, + `DTSTART:${dtStart}`, + `DTEND:${dtEnd}`, + `SUMMARY:${escapeICSText(item.text)}`, + description ? `DESCRIPTION:${escapeICSText(description)}` : '', + location ? `LOCATION:${escapeICSText(location)}` : '', + geo, + item.status ? `STATUS:${item.status.label.toUpperCase()}` : '', + 'END:VEVENT', + ] + .filter((line) => line) + .join('\r\n'); + }); + + icsContent += '\r\n' + 'END:VCALENDAR'; + const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${tripName.replace(/\s+/g, '_')}.ics`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); +} + +function escapeICSText(text: string): string { + if (!text) return ''; + return text.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n').replace(/\r/g, ''); +} diff --git a/src/src/app/components/trip/trip.component.html b/src/src/app/components/trip/trip.component.html index bd9eeb7..907ea2c 100644 --- a/src/src/app/components/trip/trip.component.html +++ b/src/src/app/components/trip/trip.component.html @@ -70,7 +70,8 @@ text /> - + +
@@ -92,7 +93,7 @@ @defer { @if (flattenedTripItems.length) { - @@ -190,7 +191,8 @@ -
{{tripitem.td_label }}
+
{{ tripitem.td_date | date: 'd MMM, y' }}
+
{{ tripitem.td_label }}
} @if (tripTableSelectedColumns.includes('time')) {{{ tripitem.time }}} @@ -250,14 +252,23 @@

- No Trip. + Empty Trip.

+ @if (trip?.days?.length) { +

+ Add Item to your Trip to start organizing ! +

+ + + } @else {

Add Day to your Trip to start organizing !

- + + }
+  {{ (d.dt | date: 'd MMM, y' ) || '-' }} {{ getDayStats(d).price || '-' }} @if (getDayStats(d).price) { {{ trip?.currency }} } @@ -911,6 +925,10 @@
{{ place.name }}
+ } @empty { +
+

Nothing there.

+
} diff --git a/src/src/app/components/trip/trip.component.ts b/src/src/app/components/trip/trip.component.ts index 16416c8..ad688da 100644 --- a/src/src/app/components/trip/trip.component.ts +++ b/src/src/app/components/trip/trip.component.ts @@ -51,6 +51,8 @@ import { orderByPipe } from '../../shared/order-by.pipe'; import { TripNotesModalComponent } from '../../modals/trip-notes-modal/trip-notes-modal.component'; import { TripArchiveModalComponent } from '../../modals/trip-archive-modal/trip-archive-modal.component'; import { FileSizePipe } from '../../shared/filesize.pipe'; +import { generateTripICSFile } from './ics'; +import { generateTripCSVFile } from './csv'; @Component({ selector: 'app-trip', @@ -202,6 +204,30 @@ export class TripComponent implements AfterViewInit { ], }, ]; + readonly menuTripExportItems: MenuItem[] = [ + { + label: 'Actions', + items: [ + { + label: 'Calendar (.ics)', + icon: 'pi pi-calendar', + command: () => generateTripICSFile(this.flattenedTripItems, this.trip?.name, this.utilsService), + }, + { + label: 'CSV', + icon: 'pi pi-file', + command: () => generateTripCSVFile(this.flattenedTripItems, this.trip?.name), + }, + { + label: 'Pretty Print', + icon: 'pi pi-print', + command: () => { + this.togglePrint(); + }, + }, + ], + }, + ]; readonly tripTableColumns: string[] = [ 'day', 'time', @@ -345,52 +371,62 @@ export class TripComponent implements AfterViewInit { } flattenTripDayItems(searchValue?: string) { + const searchLower = (searchValue || '').toLowerCase(); let prevLat: number, prevLng: number; - this.flattenedTripItems = this.trip!.days.flatMap((day) => - [...day.items] - .filter((item) => - searchValue - ? item.text.toLowerCase().includes(searchValue) || - item.place?.name.toLowerCase().includes(searchValue) || - item.comment?.toLowerCase().includes(searchValue) - : true, - ) - .sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0)) - .map((item) => { - const lat = item.lat ?? (item.place ? item.place.lat : undefined); - const lng = item.lng ?? (item.place ? item.place.lng : undefined); + this.flattenedTripItems = this.trip!.days.flatMap((day) => day.items.map((item) => ({ item, day }))) + .filter( + ({ item }) => + !searchLower || + item.text.toLowerCase().includes(searchLower) || + item.place?.name.toLowerCase().includes(searchLower) || + item.comment?.toLowerCase().includes(searchLower), + ) + .sort((a, b) => { + const dateA = a.day.dt; + const dateB = b.day.dt; + if (dateA && dateB) return dateA.localeCompare(dateB) || (a.item.time || '').localeCompare(b.item.time || ''); + if (!dateA && !dateB) { + return ( + (a.day.label || '').localeCompare(b.day.label || '') || (a.item.time || '').localeCompare(b.item.time || '') + ); + } + return dateA ? -1 : 1; + }) + .map(({ item, day }) => { + const lat = item.lat ?? item.place?.lat; + const lng = item.lng ?? item.place?.lng; - let distance: number | undefined; - if (lat && lng) { - if (prevLat && prevLng) { - const d = calculateDistanceBetween(prevLat, prevLng, lat, lng); - distance = +(Math.round(d * 1000) / 1000).toFixed(2); - } - prevLat = lat; - prevLng = lng; + let distance: number | undefined; + if (lat && lng) { + if (prevLat && prevLng) { + const d = calculateDistanceBetween(prevLat, prevLng, lat, lng); + distance = +(Math.round(d * 1000) / 1000).toFixed(2); } + prevLat = lat; + prevLng = lng; + } - return { - td_id: day.id, - td_label: day.label, - id: item.id, - time: item.time, - text: item.text, - status: this.statusToTripStatus(item.status as string), - comment: item.comment, - price: item.price || undefined, - day_id: item.day_id, - place: item.place, - image: item.image, - image_id: item.image_id, - gpx: item.gpx, - lat, - lng, - distance, - paid_by: item.paid_by, - }; - }), - ); + return { + td_id: day.id, + td_label: day.label, + td_date: day.dt, + id: item.id, + time: item.time, + text: item.text, + status: this.statusToTripStatus(item.status as string), + comment: item.comment, + price: item.price || undefined, + day_id: item.day_id, + place: item.place, + image: item.image, + image_id: item.image_id, + gpx: item.gpx, + lat, + lng, + distance, + paid_by: item.paid_by, + }; + }); } computePlacesUsedInTable() { @@ -959,7 +995,7 @@ export class TripComponent implements AfterViewInit { closable: true, dismissableMask: true, width: '50vw', - data: { day: day, days: this.trip.days }, + data: day, breakpoints: { '640px': '80vw', },