diff --git a/e2e/utils.ts b/e2e/utils.ts index 8865b79ae..17dd123a0 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -93,7 +93,7 @@ export async function setCompanyData(page: Page, user: UserCredentials, company: await expect(page.getByRole('heading', { name: 'Stammdaten Ihres Unternehmens' })).toBeVisible(); await page.getByLabel('Name').fill(company.name); - await page.getByLabel('Unternehmenssitz').pressSequentially(company.address, { delay: 10 }); + await page.getByLabel('Unternehmenssitz').pressSequentially(company.address, { delay: 50 }); await page.getByText('Werner-Seelenbinder-Straße 70a').click(); await page.getByLabel('Pflichtfahrgebiet').selectOption({ label: company.zone }); diff --git a/migrations/2025-03-21-favourites.js b/migrations/2025-03-21-favourites.js new file mode 100644 index 000000000..63053a4d4 --- /dev/null +++ b/migrations/2025-03-21-favourites.js @@ -0,0 +1,30 @@ + + +export async function up(db) { + await db.schema + .createTable('favourite_locations') + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user', 'integer', (col) => col.references('user.id').notNull()) + .addColumn('address', 'varchar', (col) => col.notNull()) + .addColumn('lat', 'real', (col) => col.notNull()) + .addColumn('lng', 'real', (col) => col.notNull()) + .addColumn('level', 'integer', (col) => col.notNull()) + .addColumn('last_timestamp', 'bigint', (col) => col.notNull()) + .addColumn('count', 'integer', (col) => col.notNull().defaultTo(1)) + .execute(); + + await db.schema + .createTable('favourite_routes') + .addColumn('id', 'serial', (col) => col.primaryKey()) + .addColumn('user', 'integer', (col) => col.references('user.id').notNull()) + .addColumn('from_id', 'integer', (col) => col.references('favourite_locations.id').notNull()) + .addColumn('to_id', 'integer', (col) => col.references('favourite_locations.id').notNull()) + .addColumn('last_timestamp', 'bigint', (col) => col.notNull()) + .addColumn('count', 'integer', (col) => col.notNull().defaultTo(1)) + .execute(); +} + +export async function down(db) { + await db.dropTable('favourite_routes').execute(); + await db.dropTable('favourite_locations').execute(); +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index c0776817b..8f4dd1f24 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -95,7 +95,10 @@ const translations: Translations = { // Feedback feedbackThank: 'Vielen Dank für Ihr Feedback!', - feedbackMissing: 'Kein Feedback gegeben' + feedbackMissing: 'Kein Feedback gegeben', + + invalidFrom: 'Invalide Start Addresse.', + invalidTo: 'Invalide Ziel Addresse.' }, admin: { completedToursSubtitle: 'Abgeschlossene Fahrten', @@ -175,6 +178,7 @@ const translations: Translations = { odm: 'ÖPNV-Taxi - Buchung erforderlich!', from: 'Von', to: 'Nach', + favourites: 'Favoriten', arrival: 'Ankunft', departure: 'Abfahrt', duration: 'Dauer', diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 887869bc5..b521b8fca 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -92,7 +92,10 @@ const translations: Translations = { // Feedback feedbackThank: 'Thank you very much for your feedback!', - feedbackMissing: 'No feedback given' + feedbackMissing: 'No feedback given', + + invalidFrom: 'Invalid start address.', + invalidTo: 'Invalid destination address.' }, admin: { completedToursSubtitle: 'Completed Tours', @@ -170,6 +173,7 @@ const translations: Translations = { odm: 'Public Transport Taxi, booking required!', from: 'From', to: 'To', + favourites: 'Favourites', arrival: 'Arrival', departure: 'Departure', duration: 'Duration', diff --git a/src/lib/i18n/translation.ts b/src/lib/i18n/translation.ts index 05f929c2a..2909e3516 100644 --- a/src/lib/i18n/translation.ts +++ b/src/lib/i18n/translation.ts @@ -94,6 +94,9 @@ export type Translations = { // Feedback feedbackThank: string; feedbackMissing: string; + + invalidFrom: string; + invalidTo: string; }; admin: { completedToursSubtitle: string; @@ -160,6 +163,7 @@ export type Translations = { odm: string; from: string; to: string; + favourites: string; arrival: string; departure: string; duration: string; diff --git a/src/lib/map/Location.ts b/src/lib/map/Location.ts index 4906aa97d..e484e52a7 100644 --- a/src/lib/map/Location.ts +++ b/src/lib/map/Location.ts @@ -9,9 +9,9 @@ export type Location = { }; }; -export function posToLocation(pos: maplibregl.LngLatLike, level: number): Location { +export function posToLocation(pos: maplibregl.LngLatLike, level: number, l?: string): Location { const { lat, lng } = maplibregl.LngLat.convert(pos); - const label = `${lat},${lng},${level}`; + const label = l ? l : `${lat},${lng},${level}`; return { label, value: { diff --git a/src/lib/server/booking/getEventGroupInfo.ts b/src/lib/server/booking/getEventGroupInfo.ts index b4d6f9972..7b365a251 100644 --- a/src/lib/server/booking/getEventGroupInfo.ts +++ b/src/lib/server/booking/getEventGroupInfo.ts @@ -1,7 +1,7 @@ import type { Coordinates } from '$lib/util/Coordinates'; import { InsertHow } from './insertionTypes'; import { v4 as uuidv4 } from 'uuid'; -import { isSamePlace } from './isSamePlace'; +import { isSamePlace } from '$lib/util/booking/isSamePlace'; import { type Event } from '$lib/server/booking/getBookingAvailability'; export type EventGroupUpdate = { diff --git a/src/lib/server/booking/routing.ts b/src/lib/server/booking/routing.ts index d758342c0..7b40cfd50 100644 --- a/src/lib/server/booking/routing.ts +++ b/src/lib/server/booking/routing.ts @@ -5,7 +5,7 @@ import type { InsertionInfo } from './insertionTypes'; import { iterateAllInsertions } from './iterateAllInsertions'; import type { VehicleId } from './VehicleId'; import type { Range } from '$lib/util/booking/getPossibleInsertions'; -import { isSamePlace } from './isSamePlace'; +import { isSamePlace } from '$lib/util/booking/isSamePlace'; import { batchOneToManyCarRouting } from '$lib/server/util/batchOneToManyCarRouting'; export type InsertionRoutingResult = { diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index d859aa4d5..e3c6e48ce 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -98,6 +98,24 @@ export interface Database { rating: number | null; comment: string | null; }; + favouriteLocations: { + id: Generated; + user: number; + address: string; + lat: number; + lng: number; + level: number; + lastTimestamp: number; + count: number; + }; + favouriteRoutes: { + id: Generated; + user: number; + fromId: number; + toId: number; + lastTimestamp: number; + count: number; + }; } export const pool = new pg.Pool({ connectionString: env.DATABASE_URL }); diff --git a/src/lib/ui/AccountingView.svelte b/src/lib/ui/AccountingView.svelte index 91c53a76b..4df815ac3 100644 --- a/src/lib/ui/AccountingView.svelte +++ b/src/lib/ui/AccountingView.svelte @@ -305,7 +305,6 @@ 'cursor-pointer '} bind:selectedRow={selectedToursTableRow} bindSelectedRow={true} @@ -318,7 +317,6 @@ {/snippet} @@ -326,7 +324,6 @@ {/snippet} diff --git a/src/lib/ui/DisplayAddresses.svelte b/src/lib/ui/DisplayAddresses.svelte new file mode 100644 index 000000000..625905236 --- /dev/null +++ b/src/lib/ui/DisplayAddresses.svelte @@ -0,0 +1,23 @@ + + +
+
+ +
+ +
+
+
{fromAddress}
+
+
{toAddress}
+
+
diff --git a/src/lib/ui/FavouriteLocations.svelte b/src/lib/ui/FavouriteLocations.svelte new file mode 100644 index 000000000..302b41557 --- /dev/null +++ b/src/lib/ui/FavouriteLocations.svelte @@ -0,0 +1,45 @@ + + +{#if favouriteRows.length != 0} + + + 'cursor-pointer '} + rows={favouriteRows} + cols={favouriteCols} + bind:selectedRow={selectedFavourite} + bindSelectedRow={true} + /> + + +{/if} diff --git a/src/lib/ui/FavouriteRoutes.svelte b/src/lib/ui/FavouriteRoutes.svelte new file mode 100644 index 000000000..683037445 --- /dev/null +++ b/src/lib/ui/FavouriteRoutes.svelte @@ -0,0 +1,51 @@ + + +
+ + + + + {t.favourites} + + + + + {#each favouriteRows as row} + { + selectedFavourite = [row]; + }} + > + + + + + {/each} + + +
diff --git a/src/lib/ui/SortableTable.svelte b/src/lib/ui/SortableTable.svelte index a4f154787..f511790b8 100644 --- a/src/lib/ui/SortableTable.svelte +++ b/src/lib/ui/SortableTable.svelte @@ -19,7 +19,6 @@ toColumnStyle?: (r: T) => string; hidden?: boolean; }[]; - isAdmin: boolean; getRowStyle?: (row: T) => string; selectedRow?: undefined | T[]; bindSelectedRow?: boolean; diff --git a/src/lib/server/booking/isSamePlace.ts b/src/lib/util/booking/isSamePlace.ts similarity index 100% rename from src/lib/server/booking/isSamePlace.ts rename to src/lib/util/booking/isSamePlace.ts diff --git a/src/routes/(customer)/routing/+page.server.ts b/src/routes/(customer)/routing/+page.server.ts index 64b0d0440..d5f81a58f 100644 --- a/src/routes/(customer)/routing/+page.server.ts +++ b/src/routes/(customer)/routing/+page.server.ts @@ -8,6 +8,230 @@ import { msg, type Msg } from '$lib/msg'; import { redirect } from '@sveltejs/kit'; import { sendMail } from '$lib/server/sendMail'; import NewRide from '$lib/server/email/NewRide.svelte'; +import type { PageServerLoadEvent } from './$types'; +import { isSamePlace } from '$lib/util/booking/isSamePlace'; +import { DAY } from '$lib/util/time'; + +export async function load(event: PageServerLoadEvent) { + const userId = event.locals.session?.userId; + if (!userId) { + return { + favouriteLocations: [], + favouriteRoutes: [] + }; + } + console.log( + 'test: ', + await db + .with('top_favourites', (qb) => + qb + .selectFrom('favouriteRoutes') + .innerJoin( + 'favouriteLocations as fromLocations', + 'fromLocations.id', + 'favouriteRoutes.fromId' + ) + .innerJoin('favouriteLocations as toLocations', 'toLocations.id', 'favouriteRoutes.toId') + .select((eb) => [ + eb.lit(1).$castTo().as('sort_order_1'), + 'favouriteRoutes.count as sort_order_2', + 'toLocations.address as toAddress', + 'toLocations.lat as toLat', + 'toLocations.lng as toLng', + 'toLocations.level as toLevel', + 'fromLocations.address as fromAddress', + 'fromLocations.lat as fromLat', + 'fromLocations.lng as fromLng', + 'fromLocations.level as fromLevel', + 'favouriteRoutes.id' + ]) + .where('favouriteRoutes.user', '=', userId) + .orderBy('favouriteRoutes.count', 'desc') + ) + .with('latest', (qb) => + qb + .selectFrom('favouriteRoutes') + .innerJoin( + 'favouriteLocations as fromLocations', + 'fromLocations.id', + 'favouriteRoutes.fromId' + ) + .innerJoin('favouriteLocations as toLocations', 'toLocations.id', 'favouriteRoutes.toId') + .select((eb) => [ + eb.lit(2).$castTo().as('sort_order_1'), + eb.ref('favouriteRoutes.lastTimestamp').$castTo().as('sort_order_2'), + 'toLocations.address as toAddress', + 'toLocations.lat as toLat', + 'toLocations.lng as toLng', + 'toLocations.level as toLevel', + 'fromLocations.address as fromAddress', + 'fromLocations.lat as fromLat', + 'fromLocations.lng as fromLng', + 'fromLocations.level as fromLevel', + 'favouriteRoutes.id' + ]) + .where('favouriteRoutes.user', '=', userId) + .where('favouriteRoutes.lastTimestamp', '>=', Date.now() - DAY) + .orderBy('favouriteRoutes.lastTimestamp', 'desc') + .limit(2) + ) + .with('combined', (qb) => + qb.selectFrom('latest').selectAll().unionAll(qb.selectFrom('top_favourites').selectAll()) + ) + .with('ranked', (qb) => + qb + .selectFrom('combined') + .selectAll() + .select(sql`ROW_NUMBER() OVER (PARTITION BY id)`.as('rn')) + ) + .selectFrom('ranked') + .orderBy('ranked.sort_order_2', 'desc') + .select([ + 'ranked.fromAddress', + 'ranked.toAddress', + 'ranked.fromLat', + 'ranked.toLat', + 'ranked.fromLng', + 'ranked.toLng', + 'ranked.fromLevel', + 'ranked.toLevel', + 'sort_order_2' + ]) + .where('ranked.rn', '=', 1) + .where('sort_order_1', '=', 2) + .execute() + ); + return { + favouriteLocations: await db + .with('top_favourites', (qb) => + qb + .selectFrom('favouriteLocations') + .select((eb) => [ + eb.lit(1).$castTo().as('sort_order_1'), + 'address', + 'lat', + 'lng', + 'level', + 'count as sort_order_2', + 'lastTimestamp', + 'id' + ]) + .where('user', '=', userId) + .orderBy('count', 'desc') + .limit(5) + ) + .with('latest', (qb) => + qb + .selectFrom('favouriteLocations') + .select((eb) => [ + eb.lit(2).$castTo().as('sort_order_1'), + 'address', + 'lat', + 'lng', + 'level', + 'count as sort_order_2', + 'lastTimestamp', + 'id' + ]) + .where('user', '=', userId) + .where('lastTimestamp', '>=', Date.now() - DAY) + .orderBy('lastTimestamp', 'desc') + .limit(2) + ) + .with('combined', (qb) => + qb.selectFrom('latest').selectAll().unionAll(qb.selectFrom('top_favourites').selectAll()) + ) + .with('ranked', (qb) => + qb + .selectFrom('combined') + .selectAll() + .select(sql`ROW_NUMBER() OVER (PARTITION BY id)`.as('rn')) + ) + .selectFrom('ranked') + .orderBy('ranked.sort_order_1', 'desc') + .orderBy('ranked.sort_order_2', 'desc') + .select(['ranked.address', 'ranked.lat', 'ranked.lng', 'ranked.level']) + .where('ranked.rn', '=', 1) + .execute(), + favouriteRoutes: await db + .with('top_favourites', (qb) => + qb + .selectFrom('favouriteRoutes') + .innerJoin( + 'favouriteLocations as fromLocations', + 'fromLocations.id', + 'favouriteRoutes.fromId' + ) + .innerJoin('favouriteLocations as toLocations', 'toLocations.id', 'favouriteRoutes.toId') + .select((eb) => [ + eb.lit(1).$castTo().as('sort_order_1'), + 'favouriteRoutes.count as sort_order_2', + 'toLocations.address as toAddress', + 'toLocations.lat as toLat', + 'toLocations.lng as toLng', + 'toLocations.level as toLevel', + 'fromLocations.address as fromAddress', + 'fromLocations.lat as fromLat', + 'fromLocations.lng as fromLng', + 'fromLocations.level as fromLevel', + 'favouriteRoutes.id' + ]) + .where('favouriteRoutes.user', '=', userId) + .orderBy('favouriteRoutes.count', 'desc') + ) + .with('latest', (qb) => + qb + .selectFrom('favouriteRoutes') + .innerJoin( + 'favouriteLocations as fromLocations', + 'fromLocations.id', + 'favouriteRoutes.fromId' + ) + .innerJoin('favouriteLocations as toLocations', 'toLocations.id', 'favouriteRoutes.toId') + .select((eb) => [ + eb.lit(2).$castTo().as('sort_order_1'), + eb.ref('favouriteRoutes.lastTimestamp').$castTo().as('sort_order_2'), + 'toLocations.address as toAddress', + 'toLocations.lat as toLat', + 'toLocations.lng as toLng', + 'toLocations.level as toLevel', + 'fromLocations.address as fromAddress', + 'fromLocations.lat as fromLat', + 'fromLocations.lng as fromLng', + 'fromLocations.level as fromLevel', + 'favouriteRoutes.id' + ]) + .where('favouriteRoutes.user', '=', userId) + .where('favouriteRoutes.lastTimestamp', '>=', Date.now() - DAY) + .orderBy('favouriteRoutes.lastTimestamp', 'desc') + .limit(2) + ) + .with('combined', (qb) => + qb.selectFrom('latest').selectAll().unionAll(qb.selectFrom('top_favourites').selectAll()) + ) + .with('ranked', (qb) => + qb + .selectFrom('combined') + .selectAll() + .select(sql`ROW_NUMBER() OVER (PARTITION BY id)`.as('rn')) + ) + .selectFrom('ranked') + .orderBy('ranked.sort_order_1', 'desc') + .orderBy('ranked.sort_order_2', 'desc') + .select([ + 'ranked.fromAddress', + 'ranked.toAddress', + 'ranked.fromLat', + 'ranked.toLat', + 'ranked.fromLng', + 'ranked.toLng', + 'ranked.fromLevel', + 'ranked.toLevel' + ]) + .where('ranked.rn', '=', 1) + .execute() + }; +} const getCommonTour = (l1: Set, l2: Set) => { for (const e of l1) { @@ -19,7 +243,7 @@ const getCommonTour = (l1: Set, l2: Set) => { }; export const actions = { - default: async ({ request, locals }): Promise<{ msg: Msg }> => { + booking: async ({ request, locals }): Promise<{ msg: Msg }> => { const user = locals.session?.userId; if (!user) { return { msg: msg('accountDoesNotExist') }; @@ -277,5 +501,151 @@ export const actions = { } return { msg: message! }; + }, + updateFavourites: async ({ request, locals }) => { + const user = locals.session?.userId; + if (!user || typeof user != 'number') { + return { msg: msg('accountDoesNotExist') }; + } + const formData = await request.formData(); + const fromAddress = formData.get('fromAddress'); + const fromLat = formData.get('fromLat'); + const fromLon = formData.get('fromLon'); + const fromLevel = formData.get('fromLevel'); + const toAddress = formData.get('toAddress'); + const toLat = formData.get('toLat'); + const toLon = formData.get('toLon'); + const toLevel = formData.get('toLevel'); + if ( + typeof fromAddress !== 'string' || + typeof fromLat !== 'string' || + typeof fromLevel !== 'string' || + typeof fromLon !== 'string' + ) { + return { msg: msg('invalidFrom') }; + } + const fromLatitude = parseFloat(fromLat); + const fromLongtitude = parseFloat(fromLon); + const fromLvl = parseInt(fromLevel); + if (isNaN(fromLatitude) || isNaN(fromLongtitude) || isNaN(fromLvl)) { + return { msg: msg('invalidFrom') }; + } + let currentFavourites = await db + .selectFrom('favouriteLocations') + .where('user', '=', user) + .selectAll() + .execute(); + const fromMatch = currentFavourites.find( + (favourite) => + isSamePlace(favourite, { lat: fromLatitude, lng: fromLongtitude }) && + favourite.level === fromLvl + ); + let fromId = undefined; + if (fromMatch) { + await db + .updateTable('favouriteLocations') + .where('user', '=', user) + .where('favouriteLocations.id', '=', fromMatch.id) + .set({ count: fromMatch.count + 1, lastTimestamp: Date.now() }) + .execute(); + } else { + fromId = (await db + .insertInto('favouriteLocations') + .values({ + lat: fromLatitude, + lng: fromLongtitude, + level: fromLvl, + address: fromAddress, + user, + count: 1, + lastTimestamp: Date.now() + }) + .returning('id') + .executeTakeFirst())!.id; + } + + if ( + typeof toAddress !== 'string' || + typeof toLat !== 'string' || + typeof toLon !== 'string' || + typeof toLevel !== 'string' + ) { + return { msg: msg('invalidFrom') }; + } + const toLatitude = parseFloat(toLat); + const toLongitude = parseFloat(toLon); + const toLvl = parseInt(toLevel); + if (isNaN(toLatitude) || isNaN(toLongitude) || isNaN(toLvl)) { + return { msg: msg('invalidFrom') }; + } + currentFavourites = await db + .selectFrom('favouriteLocations') + .where('user', '=', user) + .selectAll() + .execute(); + const toMatch = currentFavourites.find( + (favourite) => + isSamePlace(favourite, { lat: toLatitude, lng: toLongitude }) && favourite.level === toLvl + ); + let toId = undefined; + if (toMatch) { + await db + .updateTable('favouriteLocations') + .where('user', '=', user) + .where('favouriteLocations.id', '=', toMatch.id) + .set({ count: toMatch.count + 1, lastTimestamp: Date.now() }) + .execute(); + } else { + toId = (await db + .insertInto('favouriteLocations') + .values({ + lat: toLatitude, + lng: toLongitude, + level: toLvl, + address: toAddress, + user, + count: 1, + lastTimestamp: Date.now() + }) + .returning(['favouriteLocations.id']) + .executeTakeFirst())!.id; + } + toId = toId ?? toMatch?.id; + fromId = fromId ?? fromMatch?.id; + if ( + toId == undefined || + fromId == undefined || + isSamePlace({ lat: fromLatitude, lng: fromLongtitude }, { lat: toLatitude, lng: toLongitude }) + ) { + return {}; + } + if (fromMatch && toMatch) { + const currentFavouriteRoutes = await db + .selectFrom('favouriteRoutes') + .where('user', '=', user) + .where('fromId', '=', fromMatch.id) + .where('toId', '=', toMatch.id) + .select(['favouriteRoutes.id']) + .executeTakeFirst(); + if (currentFavouriteRoutes) { + await db + .updateTable('favouriteRoutes') + .where('favouriteRoutes.id', '=', currentFavouriteRoutes.id) + .set((eb) => ({ count: eb('count', '+', 1), lastTimestamp: Date.now() })) + .returning('favouriteRoutes.id') + .execute(); + } else { + await db + .insertInto('favouriteRoutes') + .values({ user, toId, fromId, count: 1, lastTimestamp: Date.now() }) + .execute(); + } + } else { + await db + .insertInto('favouriteRoutes') + .values({ user, toId, fromId, count: 1, lastTimestamp: Date.now() }) + .execute(); + } + return {}; } }; diff --git a/src/routes/(customer)/routing/+page.svelte b/src/routes/(customer)/routing/+page.svelte index 10a4f8715..e1cbeeaa9 100644 --- a/src/routes/(customer)/routing/+page.svelte +++ b/src/routes/(customer)/routing/+page.svelte @@ -1,6 +1,6 @@
- {#if page.state.selectFrom} history.back()} /> + {:else if page.state.selectTo} history.back()} /> + {:else if page.state.showMap} {:else if page.state.selectedItinerary} @@ -219,7 +300,7 @@ page.state.selectedItinerary.legs.length === 1 && page.state.selectedItinerary.legs[0].mode === 'ODM'} -
+
+ {#if baseQuery == undefined && data.favouriteRoutes && data.favouriteRoutes.length != 0} + + + + + + {/if}
{#if it.startAddress !== undefined && it.targetAddress !== undefined} - {it.startAddress} -> {it.targetAddress} + {/if}