diff --git a/package.json b/package.json index 8aac3e8..5abdf69 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", + "test": "vitest", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", @@ -16,9 +17,11 @@ }, "devDependencies": { "@fontsource/poppins": "^5.0.8", + "@playwright/test": "^1.40.1", "@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/kit": "^1.27.4", "@tailwindcss/forms": "^0.5.7", + "@types/node": "^20.11.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "autoprefixer": "^10.4.16", @@ -30,7 +33,8 @@ "tailwindcss": "^3.3.6", "tslib": "^2.4.1", "typescript": "^5.3.3", - "vite": "^4.4.2" + "vite": "^4.4.2", + "vitest": "^1.2.2" }, "type": "module", "dependencies": { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e6a22bc..5ec405b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,6 @@ import { get } from 'svelte/store'; import { getTokensLogin, getTokensRefresh, getUserInfo } from './emel-api/emel-api'; -import { token, user, userCredentials } from './stores'; +import { token, user, userCredentials } from './state'; export async function login(email: string, password: string) { const response = await getTokensLogin(email, password); diff --git a/src/lib/components/Bike.svelte b/src/lib/components/Bike.svelte index e4162b5..ef3b475 100644 --- a/src/lib/components/Bike.svelte +++ b/src/lib/components/Bike.svelte @@ -10,12 +10,14 @@ import IconSettings from '@tabler/icons-svelte/dist/svelte/icons/IconSettings.svelte'; import { tweened } from 'svelte/motion'; import { cubicOut } from 'svelte/easing'; - import { reserveBike, startTrip, updateActiveTripInfo, type ThrownError } from '../gira-api'; - import { accountInfo, addErrorMessage, appSettings, currentTrip, type StationInfo } from '$lib/stores'; + import type { ThrownError } from '../gira-api/api-types'; + import { accountInfo, addErrorMessage, appSettings, currentTrip, type StationInfo } from '$lib/state'; import { currentPos } from '$lib/location'; import { fade } from 'svelte/transition'; import { distanceBetweenCoords } from '$lib/utils'; import { LOCK_DISTANCE_m } from '$lib/constants'; + import { updateActiveTripInfo } from '$lib/state/helper'; + import { reserveBike, startTrip } from '$lib/gira-api/api'; async function checkTripStarted() { if ($currentTrip === null) return; diff --git a/src/lib/components/ErrorMessage.svelte b/src/lib/components/ErrorMessage.svelte index 0f6abd7..a733d99 100644 --- a/src/lib/components/ErrorMessage.svelte +++ b/src/lib/components/ErrorMessage.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/components/Floating.svelte b/src/lib/components/Floating.svelte index 3437da4..00d5bbe 100644 --- a/src/lib/components/Floating.svelte +++ b/src/lib/components/Floating.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/components/Profile.svelte b/src/lib/components/Profile.svelte index aa9adb7..6057d3b 100644 --- a/src/lib/components/Profile.svelte +++ b/src/lib/components/Profile.svelte @@ -1,7 +1,4 @@ {#if $user} diff --git a/src/lib/components/StationMenu.svelte b/src/lib/components/StationMenu.svelte index e768724..f3cf994 100644 --- a/src/lib/components/StationMenu.svelte +++ b/src/lib/components/StationMenu.svelte @@ -2,14 +2,14 @@ import { tweened } from 'svelte/motion'; import Bike from '$lib/components/Bike.svelte'; import { cubicOut } from 'svelte/easing'; - import { getStationInfo } from '$lib/gira-api'; import { onMount } from 'svelte'; - import { stations, selectedStation, type StationInfo } from '$lib/stores'; + import { stations, selectedStation, type StationInfo } from '$lib/state'; import { tick } from 'svelte'; import { currentPos } from '$lib/location'; import { distanceBetweenCoords, formatDistance } from '$lib/utils'; import { fade } from 'svelte/transition'; import BikeSkeleton from './BikeSkeleton.svelte'; + import { getStationInfo } from '$lib/gira-api/api'; export let bikeListHeight = 0; export let posTop:number|undefined = 0; diff --git a/src/lib/components/TripRating.svelte b/src/lib/components/TripRating.svelte index d3d0a33..4d888e0 100644 --- a/src/lib/components/TripRating.svelte +++ b/src/lib/components/TripRating.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/emel-api/emel-api.ts b/src/lib/emel-api/emel-api.ts index 91558bf..9ba2967 100644 --- a/src/lib/emel-api/emel-api.ts +++ b/src/lib/emel-api/emel-api.ts @@ -1,4 +1,4 @@ -import type { Token } from '$lib/stores'; +import type { Token } from '$lib/state'; import type { ApiResponse, TokenOpt, UserInfo } from './types'; export async function getTokensLogin(email: string, password: string) { diff --git a/src/lib/gira-api/__mocks__/api.ts b/src/lib/gira-api/__mocks__/api.ts new file mode 100644 index 0000000..7dfbd84 --- /dev/null +++ b/src/lib/gira-api/__mocks__/api.ts @@ -0,0 +1,159 @@ +import type { M, Q } from '../api-types'; + +export async function reserveBike(serialNumber: string): Promise> { + return { + reserveBike: true, + }; +} + +export async function getStationInfo(stationId: string): Promise> { + // TODO + return { getBikes: [], getDocks: [] }; +} + +export async function cancelBikeReserve(): Promise> { + return { cancelBikeReserve: true }; +} + +export async function startTrip(): Promise> { + return { + startTrip: true, + }; +} + +export async function rateTrip(tripCode: string, tripRating: number, tripComment?: string, tripAttachment?: File): Promise> { + return { + rateTrip: true, + }; +} + +export async function tripPayWithNoPoints(tripCode: string): Promise> { + // TODO check what this actually returns? + return { + tripPayWithNoPoints: 123, + }; +} + +export async function tripPayWithPoints(tripCode: string): Promise> { + // TODO check what this actually returns? + return { + tripPayWithPoints: 123, + }; +} + +export async function fullOnetimeInfo(): Promise> { + return { + activeTrip: null, + client: [{ balance: 4, bonus: 12345 }], + getStations: [{ + 'code': '0000000003', + 'description': 'Alameda dos Oceanos', + 'latitude': 38.756161, + 'longitude': -9.096804, + 'name': '101 - Alameda dos Oceanos / Rua dos Argonautas', + 'bikes': 5, + 'docks': 14, + 'serialNumber': '1000101', + 'assetStatus': 'active', + }, + { + 'code': '0000000005', + 'description': 'Rua do Fogo de Santelmo', + 'latitude': 38.761218, + 'longitude': -9.095019, + 'name': '103 - Jardim da Água', + 'bikes': 2, + 'docks': 17, + 'serialNumber': '1000103', + 'assetStatus': 'active', + }], + activeUserSubscriptions: [ + { + 'expirationDate': '2024-10-01T00:00:18Z', + 'subscriptionStatus': 'paid', + 'name': 'Passe Anual', + 'type': 'anual', + 'active': true, + }, + ], + unratedTrips: [ + { + 'code': 'JWA8FQ1PFL', + 'startDate': '2024-01-01T13:45:50Z', + 'endDate': '2024-01-01T14:01:56Z', + 'rating': null, + 'startLocation': '0000009904', + 'endLocation': '0000009905', + 'cost': 0, + 'costBonus': 0, + 'asset': '0000013066', + }, + ], + tripHistory: (await getTripHistory(0, 10)).tripHistory, + }; +} + +export async function getTripHistory(pageNum: number, pageSize: number): Promise> { + return { + tripHistory: [ + { + 'code': '6JWFVI9O8N', + 'startDate': '2024-01-18T20:32:38Z', + 'endDate': '2024-01-18T20:51:52Z', + 'rating': 5, + 'bikeName': 'E0593', + 'startLocation': '419 - Av. António José de Almeida / Instituto Superior Técnico', + 'endLocation': '484 - Rua Professor Vieira de Almeida', + 'bonus': 10, + 'usedPoints': 0, + 'cost': 0, + 'bikeType': 'electric', + }, + { + 'code': 'JWA8FQ1PFL', + 'startDate': '2024-01-18T13:45:50Z', + 'endDate': '2024-01-18T14:01:56Z', + 'rating': 0, + 'bikeName': 'E0967', + 'startLocation': '484 - Rua Professor Vieira de Almeida', + 'endLocation': '420 - Av. Rovisco Pais / Av. Manuel da Maia', + 'bonus': 110, + 'usedPoints': 0, + 'cost': 0, + 'bikeType': 'electric', + }, + { + 'code': '5BRNS2SR5A', + 'startDate': '2024-01-17T23:23:02Z', + 'endDate': '2024-01-17T23:32:21Z', + 'rating': 5, + 'bikeName': 'E0154', + 'startLocation': '476 - Av. Professor Gama Pinto / Reitoria', + 'endLocation': '484 - Rua Professor Vieira de Almeida', + 'bonus': 10, + 'usedPoints': 0, + 'cost': 0, + 'bikeType': 'electric', + }, + { + 'code': 'DIIOWVFVSL', + 'startDate': '2024-01-17T19:28:10Z', + 'endDate': '2024-01-17T19:35:16Z', + 'rating': 5, + 'bikeName': 'E1953', + 'startLocation': '483 - Rua Professor Francisco Lucas Pires ', + 'endLocation': '475 - Av. Professor Gama Pinto / Cantina Velha', + 'bonus': 110, + 'usedPoints': 0, + 'cost': 0, + 'bikeType': 'electric', + }, + ], + }; +} + +export async function getActiveTripInfo(): Promise> { + return { + activeTrip: null, + }; +} \ No newline at end of file diff --git a/src/lib/gira-api/types.ts b/src/lib/gira-api/api-types.ts similarity index 99% rename from src/lib/gira-api/types.ts rename to src/lib/gira-api/api-types.ts index cbf0cf1..513fc88 100644 --- a/src/lib/gira-api/types.ts +++ b/src/lib/gira-api/api-types.ts @@ -1,3 +1,10 @@ +export type ThrownError = { + errors: {message:string}[]; + status: number; +}; +export type Q = {[K in T[number]]:Query[K]}; +export type M = {[K in T[number]]:Mutation[K]}; + export type Maybe = T | null export type Exact = { [K in keyof T]: T[K] diff --git a/src/lib/gira-api/api.ts b/src/lib/gira-api/api.ts new file mode 100644 index 0000000..8b2a635 --- /dev/null +++ b/src/lib/gira-api/api.ts @@ -0,0 +1,264 @@ +import { dev } from '$app/environment'; +import { CapacitorHttp, type HttpResponse } from '@capacitor/core'; +import { get } from 'svelte/store'; +import { Preferences } from '@capacitor/preferences'; +import type { M, Q, ThrownError } from './api-types'; +import type { Mutation, Query } from './api-types'; +import { token } from '$lib/state'; +const RETRY_DELAY = 1000; +const RETRIES = 5; +let backoff = 0; + +async function mutate(body:any): Promise> { + let res: HttpResponse = { status: 0, data: {}, headers: {}, url: '' }; + for (let tryNum = 0; tryNum < RETRIES; tryNum++) { + res = await CapacitorHttp.post({ + url: 'https://apigira.emel.pt/graphql', + headers: { + 'User-Agent': 'Gira/3.2.8 (Android 34)', + 'content-type': 'application/json', + 'authorization': `Bearer ${get(token)?.accessToken}`, + }, + data: body, + }); + if (res.status >= 200 && res.status < 300) { + console.debug(body, res); + backoff = RETRY_DELAY; + return res.data.data as Promise>; + } else { + console.debug('error in mutate', res); + } + await new Promise(resolve => setTimeout(resolve, backoff += 1000)); + } + console.error('failed mutation with body', body, res); + throw { + errors: res.data.errors, + status: res.status, + } as ThrownError; +} + +async function query(body:any): Promise> { + let res: HttpResponse = { status: 0, data: {}, headers: {}, url: '' }; + for (let tryNum = 0; tryNum < RETRIES; tryNum++) { + res = await CapacitorHttp.post({ + url: 'https://apigira.emel.pt/graphql', + headers: { + 'User-Agent': 'Gira/3.2.8 (Android 34)', + 'content-type': 'application/json', + 'authorization': `Bearer ${get(token)?.accessToken}`, + }, + data: body, + }); + if (res.status >= 200 && res.status < 300) { + console.debug(body, res); + backoff = RETRY_DELAY; + return res.data.data as Promise>; + } else { + console.debug('error in query', res); + } + await new Promise(resolve => setTimeout(resolve, backoff += 1000)); + } + console.error('failed query with body', body, res); + throw { + errors: res.data.errors, + status: res.status, + } as ThrownError; +} + +// async getStations(): Promise> { +// const req = query<['getStations']>({ +// 'operationName': 'getStations', +// 'variables': {}, +// 'query': 'query getStations {getStations {code, description, latitude, longitude, name, bikes, docks, serialNumber, assetStatus }}', +// }); +// return req; +// } + +export function getStationInfo(stationId: string): Promise> { + const req = query<['getBikes', 'getDocks']>({ + 'variables': { input: stationId }, + 'query': `query { + getBikes(input: "${stationId}") { battery, code, name, kms, serialNumber, type, parent } + getDocks(input: "${stationId}") { ledStatus, lockStatus, serialNumber, code, name } + }`, + }); + return req; +} + +// async getBikes(stationId: string): Promise> { +// const req = query<['getBikes']>({ +// 'variables': { input: stationId }, +// 'query': `query ($input: String) { getBikes(input: $input) { type, kms, battery, serialNumber, assetType, assetStatus, assetCondition, parent, warehouse, zone, location, latitude, longitude, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version }}`, +// }); +// return req; +// } + +// async getDocks(stationId: string): Promise> { +// const req = query<['getDocks']>({ +// 'variables': { input: stationId }, +// 'query': `query ($input: String) { getDocks(input: $input) { ledStatus, lockStatus, serialNumber, assetType, assetStatus, assetCondition, parent, warehouse, zone, location, latitude, longitude, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version }}`, +// }); +// return req; +// } + +export async function reserveBike(serialNumber: string) { + if (dev && (await Preferences.get({ key: 'settings/mockUnlock' })).value === 'true') { + console.debug('mock reserveBike'); + return { reserveBike: true }; + } else { + const req = mutate<['reserveBike']>({ + 'variables': { input: serialNumber }, + 'query': `mutation ($input: String) { reserveBike(input: $input) }`, + }); + return req; + } +} + +export async function cancelBikeReserve() { + const req = mutate<['cancelBikeReserve']>({ + 'variables': {}, + 'query': `mutation { cancelBikeReserve }`, + }); + return req; +} + +export async function startTrip() { + if (dev && (await Preferences.get({ key: 'settings/mockUnlock' })).value === 'true') { + console.debug('mock startTrip'); + await new Promise(resolve => setTimeout(resolve, 2000)); + return { startTrip: true }; + } else { + const req = mutate<['startTrip']>({ + 'variables': {}, + 'query': `mutation { startTrip }`, + }); + return req; + } +} +// // returns an int or float of the active trip cost +// async get_active_trip_cost(){ +// response = await make_post_request("https://apigira.emel.pt/graphql", JSON.stringify({ +// "operationName": "activeTripCost", +// "variables": {}, +// "query": "query activeTripCost {activeTripCost}" +// }), user.accessToken) +// return response.data.activeTripCost +// } + +// async getActiveTripCost() { +// const req = function query<['activeTripCost']>({ +// 'variables': {}, +// 'query': `query { activeTripCost }`, +// }); +// return req; +// } + +// async getPointsAndBalance() { +// const req = function query<['client']>({ +// 'variables': {}, +// 'query': `query { client { balance, bonus } }`, +// }); +// return req; +// } + +// async updateAccountInfo() { +// getPointsAndBalance().then(this.ingestAccountInfo); +// } + +// async getSubscriptions() { +// const req = function query<['activeUserSubscriptions']>({ +// 'variables': {}, +// 'query': `query { activeUserSubscriptions { expirationDate subscriptionStatus name type active } }`, +// }); +// return req; +// } + +// async updateSubscriptions() { +// getSubscriptions().then(this.function ingestSubscriptions); +// } + +export async function getTrip(tripCode:string) { + const req = query<['getTrip']>({ + 'variables': { input: tripCode }, + 'query': `query ($input: String) { getTrip(input: $input) { user, asset, startDate, endDate, startLocation, endLocation, distance, rating, photo, cost, startOccupation, endOccupation, totalBonus, client, costBonus, comment, compensationTime, endTripDock, tripStatus, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version } }`, + }); + return req; +} + +export async function getActiveTripInfo() { + const req = query<['activeTrip']>({ + 'variables': {}, + 'query': `query { activeTrip { user, startDate, endDate, startLocation, endLocation, distance, rating, photo, cost, startOccupation, endOccupation, totalBonus, client, costBonus, comment, compensationTime, endTripDock, tripStatus, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version } }`, + }); + return req; +} + +export async function getTripHistory(pageNum:number, pageSize:number) { + const req = query<['tripHistory']>({ + 'variables': { input: { _pageNum: pageNum, _pageSize: pageSize } }, + 'query': `query ($input: PageInput) { tripHistory(pageInput: $input) { code, startDate, endDate, rating, bikeName, startLocation, endLocation, bonus, usedPoints, cost, bikeType } }`, + }); + return req; +} + +export async function getUnratedTrips(pageNum:number, pageSize:number) { + const req = query<['unratedTrips']>({ + 'variables': { input: { _pageNum: pageNum, _pageSize: pageSize } }, + 'query': `query ($input: PageInput) { unratedTrips(pageInput: $input) { code, startDate, endDate, rating, startLocation, endLocation, cost, costBonus, asset } }`, + }); + return req; +} + +export async function rateTrip(tripCode:string, tripRating:number, tripComment?:string, tripAttachment?:File) { + if (tripComment === undefined) tripComment = ''; + const actualAttachment = tripAttachment === undefined ? null : tripAttachment; + const req = mutate<['rateTrip']>({ + 'variables': { + in: { + code: tripCode, + rating: tripRating, + description: tripComment, + attachment: actualAttachment !== null ? { + bytes: actualAttachment?.arrayBuffer() ?? null, + fileName: `img_${tripCode}.png`, + mimeType: 'image/png', + } : null, + }, + }, + 'query': `mutation ($in: RateTrip_In) { rateTrip(in: $in) }`, + }); + return req; +} + +export async function tripPayWithNoPoints(tripCode: string) { + const req = mutate<['tripPayWithNoPoints']>({ + 'variables': { input: tripCode }, + 'query': `mutation ($input: String) { tripPayWithNoPoints(input: $input) }`, + }); + return req; +} + +export async function tripPayWithPoints(tripCode:string) { + const req = mutate<['tripPayWithPoints']>({ + 'variables': { input: tripCode }, + 'query': `mutation ($input: String) { tripPayWithPoints(input: $input) }`, + }); + return req; +} + +export async function fullOnetimeInfo(): Promise> { + let megaQuery = `query {`; + megaQuery += `getStations {code, description, latitude, longitude, name, bikes, docks, serialNumber, assetStatus } `; + megaQuery += `client { balance, bonus } `; + megaQuery += `activeUserSubscriptions { expirationDate subscriptionStatus name type active } `; + megaQuery += `activeTrip { user, startDate, endDate, startLocation, endLocation, distance, rating, photo, cost, startOccupation, endOccupation, totalBonus, client, costBonus, comment, compensationTime, endTripDock, tripStatus, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version } `; + megaQuery += `unratedTrips(pageInput: { _pageNum: 0, _pageSize: 1 }) { code, startDate, endDate, rating, startLocation, endLocation, cost, costBonus, asset } `; + megaQuery += `tripHistory(pageInput: { _pageNum: 0, _pageSize: 1 }) { code, startDate, endDate, rating, bikeName, startLocation, endLocation, bonus, usedPoints, cost, bikeType } `; + megaQuery += `}`; + + const req = await query<['getStations', 'client', 'activeUserSubscriptions', 'activeTrip', 'unratedTrips', 'tripHistory']>({ + 'variables': {}, + 'query': megaQuery, + }); + return req; +} \ No newline at end of file diff --git a/src/lib/gira-api/index.ts b/src/lib/gira-api/index.ts deleted file mode 100644 index 7682a4e..0000000 --- a/src/lib/gira-api/index.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { dev } from '$app/environment'; -import { accountInfo, currentTrip, stations, token, tripRating, type StationInfo } from '$lib/stores'; -import { CapacitorHttp, type HttpResponse } from '@capacitor/core'; -import { get } from 'svelte/store'; -import type { Mutation, Query } from './types'; -import { Preferences } from '@capacitor/preferences'; -type Q = {[K in T[number]]:Query[K]}; -type M = {[K in T[number]]:Mutation[K]}; -export type ThrownError = { - errors: {message:string}[]; - status: number; -}; - -const retries = 5; -const retryDelay = 1000; -let backoff = 1000; - -async function mutate(body:any): Promise> { - let res: HttpResponse = { status: 0, data: {}, headers: {}, url: '' }; - for (let tryNum = 0; tryNum < retries; tryNum++) { - res = await CapacitorHttp.post({ - url: 'https://apigira.emel.pt/graphql', - headers: { - 'User-Agent': 'Gira/3.2.8 (Android 34)', - 'content-type': 'application/json', - 'authorization': `Bearer ${get(token)?.accessToken}`, - }, - data: body, - }); - if (res.status >= 200 && res.status < 300) { - console.debug(body, res); - backoff = retryDelay; - return res.data.data as Promise>; - } else { - console.debug('error in mutate', res); - } - await new Promise(resolve => setTimeout(resolve, backoff += 1000)); - } - console.error('failed mutation with body', body, res); - throw { - errors: res.data.errors, - status: res.status, - } as ThrownError; -} -async function query(body:any): Promise> { - let res: HttpResponse = { status: 0, data: {}, headers: {}, url: '' }; - for (let tryNum = 0; tryNum < retries; tryNum++) { - res = await CapacitorHttp.post({ - url: 'https://apigira.emel.pt/graphql', - headers: { - 'User-Agent': 'Gira/3.2.8 (Android 34)', - 'content-type': 'application/json', - 'authorization': `Bearer ${get(token)?.accessToken}`, - }, - data: body, - }); - if (res.status >= 200 && res.status < 300) { - console.debug(body, res); - backoff = retryDelay; - return res.data.data as Promise>; - } else { - console.debug('error in query', res); - } - await new Promise(resolve => setTimeout(resolve, backoff += 1000)); - } - console.error('failed query with body', body, res); - throw { - errors: res.data.errors, - status: res.status, - } as ThrownError; -} - -export async function getStations(): Promise> { - const req = query<['getStations']>({ - 'operationName': 'getStations', - 'variables': {}, - 'query': 'query getStations {getStations {code, description, latitude, longitude, name, bikes, docks, serialNumber, assetStatus }}', - }); - return req; -} - -export async function updateStations() { - getStations().then(ingestStations); -} - -function ingestStations(maybeStations:Q<['getStations']>) { - if (maybeStations.getStations === null || maybeStations.getStations === undefined) return; - const stationsList:StationInfo[] = []; - maybeStations.getStations.forEach(station => { - if (station === null || station === undefined) return; - const { code, name, description, latitude, longitude, bikes, docks, serialNumber, assetStatus } = station; - if (code === null || code === undefined || name === null || - name === undefined || description === undefined || - latitude === null || latitude === undefined || longitude === null || - longitude === undefined || bikes === null || bikes === undefined || - docks === null || docks === undefined || serialNumber === null || - serialNumber === undefined || assetStatus === null || assetStatus === undefined - ) { - console.error('invalid station', station); - return; - } - stationsList.push({ code, name, description, latitude, longitude, bikes, docks, serialNumber, assetStatus }); - }); - stations.set(stationsList); -} - -export async function getStationInfo(stationId: string): Promise> { - const req = query<['getBikes', 'getDocks']>({ - 'variables': { input: stationId }, - 'query': `query { - getBikes(input: "${stationId}") { battery, code, name, kms, serialNumber, type, parent } - getDocks(input: "${stationId}") { ledStatus, lockStatus, serialNumber, code, name } - }`, - }); - return req; -} - -export async function getBikes(stationId: string): Promise> { - const req = query<['getBikes']>({ - 'variables': { input: stationId }, - 'query': `query ($input: String) { getBikes(input: $input) { type, kms, battery, serialNumber, assetType, assetStatus, assetCondition, parent, warehouse, zone, location, latitude, longitude, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version }}`, - }); - return req; -} - -export async function getDocks(stationId: string): Promise> { - const req = query<['getDocks']>({ - 'variables': { input: stationId }, - 'query': `query ($input: String) { getDocks(input: $input) { ledStatus, lockStatus, serialNumber, assetType, assetStatus, assetCondition, parent, warehouse, zone, location, latitude, longitude, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version }}`, - }); - return req; -} - -export async function reserveBike(serialNumber: string) { - if (dev && (await Preferences.get({ key: 'settings/mockUnlock' })).value === 'true') { - console.debug('mock reserveBike'); - return { reserveBike: true }; - } else { - const req = mutate<['reserveBike']>({ - 'variables': { input: serialNumber }, - 'query': `mutation ($input: String) { reserveBike(input: $input) }`, - }); - return req; - } -} - -export async function cancelBikeReserve() { - const req = mutate<['cancelBikeReserve']>({ - 'variables': {}, - 'query': `mutation { cancelBikeReserve }`, - }); - return req; -} - -export async function startTrip() { - if (dev && (await Preferences.get({ key: 'settings/mockUnlock' })).value === 'true') { - console.debug('mock startTrip'); - await new Promise(resolve => setTimeout(resolve, 2000)); - return { startTrip: true }; - } else { - const req = mutate<['startTrip']>({ - 'variables': {}, - 'query': `mutation { startTrip }`, - }); - return req; - } -} -// // returns an int or float of the active trip cost -// async function get_active_trip_cost(){ -// response = await make_post_request("https://apigira.emel.pt/graphql", JSON.stringify({ -// "operationName": "activeTripCost", -// "variables": {}, -// "query": "query activeTripCost {activeTripCost}" -// }), user.accessToken) -// return response.data.activeTripCost -// } - -export async function getActiveTripCost() { - const req = query<['activeTripCost']>({ - 'variables': {}, - 'query': `query { activeTripCost }`, - }); - return req; -} - -export async function getPointsAndBalance() { - const req = query<['client']>({ - 'variables': {}, - 'query': `query { client { balance, bonus } }`, - }); - return req; -} - -export async function updateAccountInfo() { - getPointsAndBalance().then(ingestAccountInfo); -} - -export function ingestAccountInfo(maybePointsAndBalance:Q<['client']>) { - if (maybePointsAndBalance.client === null || maybePointsAndBalance.client === undefined || maybePointsAndBalance.client.length <= 0) return; - const { balance, bonus } = maybePointsAndBalance.client[0]!; - if (balance === null || balance === undefined || bonus === null || bonus === undefined) return; - accountInfo.update(ai => ( - { subscription: ai?.subscription ?? null, balance, bonus } - )); -} - -export async function getSubscriptions() { - const req = query<['activeUserSubscriptions']>({ - 'variables': {}, - 'query': `query { activeUserSubscriptions { expirationDate subscriptionStatus name type active } }`, - }); - return req; -} - -export async function updateSubscriptions() { - getSubscriptions().then(ingestSubscriptions); -} - -export async function ingestSubscriptions(maybeSubscriptions:Q<['activeUserSubscriptions']>) { - if (maybeSubscriptions.activeUserSubscriptions === null || maybeSubscriptions.activeUserSubscriptions === undefined || maybeSubscriptions.activeUserSubscriptions.length <= 0) return; - const { active, expirationDate, name, subscriptionStatus, type } = maybeSubscriptions.activeUserSubscriptions[0]!; - accountInfo.update(ai => ( - { balance: ai?.balance ?? 0, bonus: ai?.bonus ?? 0, subscription: { active: active!, expirationDate: new Date(expirationDate), name: name!, subscriptionStatus: subscriptionStatus!, type: type ?? 'unknown' } } - )); -} - -export async function getTrip(tripCode:string) { - const req = query<['getTrip']>({ - 'variables': { input: tripCode }, - 'query': `query ($input: String) { getTrip(input: $input) { user, asset, startDate, endDate, startLocation, endLocation, distance, rating, photo, cost, startOccupation, endOccupation, totalBonus, client, costBonus, comment, compensationTime, endTripDock, tripStatus, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version } }`, - }); - return req; -} - -export async function getActiveTripInfo() { - const req = query<['activeTrip']>({ - 'variables': {}, - 'query': `query { activeTrip { user, startDate, endDate, startLocation, endLocation, distance, rating, photo, cost, startOccupation, endOccupation, totalBonus, client, costBonus, comment, compensationTime, endTripDock, tripStatus, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version } }`, - }); - return req; -} - -export async function updateActiveTripInfo() { - getActiveTripInfo().then(ingestActiveTripInfo); -} -function ingestActiveTripInfo(maybeTrips:Q<['activeTrip']>) { - if (maybeTrips.activeTrip === null || maybeTrips.activeTrip === undefined || maybeTrips.activeTrip.code === 'no_trip' || maybeTrips.activeTrip.asset === 'dummy') { - if (get(currentTrip)?.confirmed || Date.now() - (get(currentTrip)?.startDate?.getTime() ?? 0) > 30000) { - currentTrip.set(null); - } - return; - } - const { - // asset, - startDate, - code, - // user, - // endDate, - // startLocation, - // endLocation, - // rating, - // photo, - // cost, - // startOccupation, - // endOccupation, - // totalBonus, - // client, - // costBonus, - // comment, - // compensationTime, - // endTripDock, - // tripStatus, - // name, - // description, - // creationDate, - // createdBy, - // updateDate, - // updatedBy, - // defaultOrder, - // version, - } = maybeTrips.activeTrip!; - currentTrip.update(ct => ct ? { - code: code!, - bikePlate: ct.bikePlate, - startPos: ct.startPos, - destination: ct.destination, - traveledDistanceKm: ct.traveledDistanceKm, - distanceLeft: ct.distanceLeft, - speed: ct.speed, - startDate: new Date(startDate!), - predictedEndDate: ct.predictedEndDate, - arrivalTime: ct.predictedEndDate, - finished: false, - confirmed: true, - pathTaken: ct.pathTaken, - } : { - code: code!, - bikePlate: null, - startPos: null, - destination: null, - traveledDistanceKm: 0, - distanceLeft: null, - speed: 0, - startDate: new Date(startDate!), - predictedEndDate: null, - arrivalTime: null, - finished: false, - confirmed: true, - pathTaken: [], - }); -} - -export async function getTripHistory(pageNum:number, pageSize:number) { - const req = query<['tripHistory']>({ - 'variables': { input: { _pageNum: pageNum, _pageSize: pageSize } }, - 'query': `query ($input: PageInput) { tripHistory(pageInput: $input) { code, startDate, endDate, rating, bikeName, startLocation, endLocation, bonus, usedPoints, cost, bikeType } }`, - }); - return req; -} - -export async function getUnratedTrips(pageNum:number, pageSize:number) { - const req = query<['unratedTrips']>({ - 'variables': { input: { _pageNum: pageNum, _pageSize: pageSize } }, - 'query': `query ($input: PageInput) { unratedTrips(pageInput: $input) { code, startDate, endDate, rating, startLocation, endLocation, cost, costBonus, asset } }`, - }); - return req; -} - -export async function updateLastUnratedTrip() { - const q = `query ($input: PageInput) { unratedTrips(pageInput: $input) { code, startDate, endDate, rating, startLocation, endLocation, cost, costBonus, asset } - tripHistory(pageInput: $input) { code, startDate, endDate, rating, bikeName, startLocation, endLocation, bonus, usedPoints, cost, bikeType } }`; - - const req = await query<['unratedTrips', 'tripHistory']>({ - 'variables': { input: { _pageNum: 0, _pageSize: 1 } }, - 'query': q, - }); - ingestLastUnratedTrip(req); -} - -function ingestLastUnratedTrip(lastTripData:Q<['unratedTrips', 'tripHistory']>) { - if (lastTripData.unratedTrips === null || lastTripData.unratedTrips === undefined || lastTripData.unratedTrips.length <= 0) return; - const unratedTrip = lastTripData.unratedTrips[0]; - if (unratedTrip == null || unratedTrip.code == null || unratedTrip.asset == null) return; - const endToNow = (new Date).getTime() - new Date(unratedTrip.endDate).getTime(); - // check if 24h have passed - if (!(endToNow < 24 * 60 * 60 * 1000)) return; - let bikePlate; - if (lastTripData.tripHistory !== null && lastTripData.tripHistory !== undefined && lastTripData.tripHistory.length > 0) { - const lastTrip = lastTripData.tripHistory[0]; - if (lastTrip) { - const lastTripCode = lastTrip?.code; - bikePlate = lastTrip.bikeName; - if (lastTripCode !== unratedTrip.code) return; - } - } - - tripRating.set({ - currentRating: { - code: unratedTrip.code, - // probably have to translate asset to bike id - bikePlate: bikePlate ?? '???', - startDate: new Date(unratedTrip.startDate), - endDate: new Date(unratedTrip.endDate), - tripPoints: unratedTrip.costBonus || 0, - }, - }); -} -// input RateTrip_In { -// code: String -// rating: Int -// description: String -// attachment: Attachment -// } -export async function rateTrip(tripCode:string, tripRating:number, tripComment?:string, tripAttachment?:File) { - if (tripComment === undefined) tripComment = ''; - const actualAttachment = tripAttachment === undefined ? null : tripAttachment; - const req = mutate<['rateTrip']>({ - 'variables': { - in: { - code: tripCode, - rating: tripRating, - description: tripComment, - attachment: actualAttachment !== null ? { - bytes: actualAttachment?.arrayBuffer() ?? null, - fileName: `img_${tripCode}.png`, - mimeType: 'image/png', - } : null, - }, - }, - 'query': `mutation ($in: RateTrip_In) { rateTrip(in: $in) }`, - }); - return req; -} - -export async function tripPayWithNoPoints(tripCode: string) { - const req = mutate<['tripPayWithNoPoints']>({ - 'variables': { input: tripCode }, - 'query': `mutation ($input: String) { tripPayWithNoPoints(input: $input) }`, - }); - return req; -} -export async function tripPayWithPoints(tripCode:string) { - const req = mutate<['tripPayWithPoints']>({ - 'variables': { input: tripCode }, - 'query': `mutation ($input: String) { tripPayWithPoints(input: $input) }`, - }); - return req; -} - -export async function updateOnetimeInfo() { - let megaQuery = `query {`; - megaQuery += `getStations {code, description, latitude, longitude, name, bikes, docks, serialNumber, assetStatus } `; - megaQuery += `client { balance, bonus } `; - megaQuery += `activeUserSubscriptions { expirationDate subscriptionStatus name type active } `; - megaQuery += `activeTrip { user, startDate, endDate, startLocation, endLocation, distance, rating, photo, cost, startOccupation, endOccupation, totalBonus, client, costBonus, comment, compensationTime, endTripDock, tripStatus, code, name, description, creationDate, createdBy, updateDate, updatedBy, defaultOrder, version } `; - megaQuery += `unratedTrips(pageInput: { _pageNum: 0, _pageSize: 1 }) { code, startDate, endDate, rating, startLocation, endLocation, cost, costBonus, asset } `; - megaQuery += `tripHistory(pageInput: { _pageNum: 0, _pageSize: 1 }) { code, startDate, endDate, rating, bikeName, startLocation, endLocation, bonus, usedPoints, cost, bikeType } `; - megaQuery += `}`; - - const req = await query<['getStations', 'client', 'activeUserSubscriptions', 'activeTrip', 'unratedTrips', 'tripHistory']>({ - 'variables': {}, - 'query': megaQuery, - }); - - ingestAccountInfo(req); - ingestSubscriptions(req); - ingestActiveTripInfo(req); - ingestStations(req); - ingestLastUnratedTrip(req); -} \ No newline at end of file diff --git a/src/lib/gira-api/ws-types.ts b/src/lib/gira-api/ws-types.ts index 6340d8c..037e17a 100644 --- a/src/lib/gira-api/ws-types.ts +++ b/src/lib/gira-api/ws-types.ts @@ -20,7 +20,7 @@ export type ActiveTripSubscription = { bike: string; startDate: Date; endDate: Date|null; - cost: null; + cost: null|number; finished: boolean; canPayWithMoney: boolean|null; canUsePoints: boolean|null; diff --git a/src/lib/gira-api/ws.ts b/src/lib/gira-api/ws.ts index 90a42ee..c226672 100644 --- a/src/lib/gira-api/ws.ts +++ b/src/lib/gira-api/ws.ts @@ -1,27 +1,21 @@ -import { currentTrip, stations, token, tripRating } from '$lib/stores'; +import { token } from '$lib/state'; import { get } from 'svelte/store'; -import type { ActiveTripSubscription, WSEvent } from './ws-types'; -import { tripPayWithNoPoints, tripPayWithPoints } from '.'; -import { currentPos } from '$lib/location'; -export let ws: WebSocket; - -function randomUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = Math.random() * 16 | 0, v = c == 'x' ? r : r & 0x3 | 0x8; - return v.toString(16); - }); -} +import type { WSEvent } from './ws-types'; +import { randomUUID } from '$lib/utils'; +import { updateWithTripMessage } from '$lib/state/helper'; +import { ingestStations } from '$lib/state/mutate'; +let ws:WebSocket; export function startWS() { console.debug('starting ws'); const tokens = get(token); const access = tokens?.accessToken; if (!access) return; - if (ws) { - ws.onclose = () => {}; - if (ws.readyState === WebSocket.OPEN) ws.close(); + if (ws.readyState === WebSocket.CONNECTING) return; + if (ws.readyState === WebSocket.OPEN) return; } + ws = new WebSocket('wss://apigira.emel.pt/graphql', 'graphql-ws'); ws.onopen = () => { backoff = 0; @@ -60,10 +54,10 @@ export function startWS() { const data = payload.data; if (data.operationalStationsSubscription) { console.debug('updated stations with websocket'); - stations.set(data.operationalStationsSubscription); + ingestStations({ getStations: data.operationalStationsSubscription }); } else if (data.activeTripSubscription) { const recvTrip = data.activeTripSubscription; - ingestTripMessage(recvTrip); + updateWithTripMessage(recvTrip); } else if (data.serverDate) { console.debug('serverdate', data.serverDate.date); console.debug('serverdate diff', new Date(data.serverDate.date).getTime() - Date.now(), 'ms'); @@ -73,12 +67,12 @@ export function startWS() { }; let backoff = 0; - function restartWS() { + const restartWS = () => { setTimeout(() => { startWS(); backoff += 1000; }, backoff); - } + }; ws.onclose = e => { console.debug('ws closed', e); restartWS(); @@ -87,74 +81,4 @@ export function startWS() { console.debug('ws error', e); restartWS(); }; -} -function ingestTripMessage(recvTrip:ActiveTripSubscription) { - console.debug('ingesting trip message from ws', recvTrip); - if (recvTrip.code === 'no_trip' || recvTrip.bike === 'dummy') { - if (get(currentTrip)?.confirmed || Date.now() - (get(currentTrip)?.startDate?.getTime() ?? 0) > 30000) { - currentTrip.set(null); - } - return; - } - - if (recvTrip.finished) { - if (recvTrip.canUsePoints) tripPayWithPoints(recvTrip.code); - else if (recvTrip.canPayWithMoney) tripPayWithNoPoints(recvTrip.code); - } - - const ctrip = get(currentTrip); - if (recvTrip.code === ctrip?.code) ingestCurrentTripUpdate(recvTrip); - else ingestOtherTripUpdate(recvTrip); -} - -function ingestCurrentTripUpdate(recvTrip:ActiveTripSubscription) { - currentTrip.update(trip => { - // if trip finished, rate, else, update trip stuff - if (recvTrip.finished) { - currentTrip.set(null); - tripRating.update(rating => { - rating.currentRating = { - code: recvTrip.code, - bikePlate: recvTrip.bike, - startDate: new Date(recvTrip.startDate), - endDate: new Date(recvTrip.endDate ?? 0), - tripPoints: recvTrip.tripPoints ?? 0, - }; - return rating; - }); - return null; - } else { - if (trip === null) throw new Error('trip is null in impossible place'); - return { - ...trip, - startDate: new Date(recvTrip.startDate), - bikePlate: recvTrip.bike, - code: recvTrip.code, - confirmed: true, - }; - } - }); -} -function ingestOtherTripUpdate(recvTrip:ActiveTripSubscription) { - if (recvTrip.finished) return; - const p = get(currentPos); - currentTrip.set({ - startDate: new Date(recvTrip.startDate), - bikePlate: recvTrip.bike, - code: recvTrip.code, - finished: recvTrip.finished, - startPos: null, - destination: null, - traveledDistanceKm: 0, - distanceLeft: null, - speed: 0, - predictedEndDate: null, - arrivalTime: null, - confirmed: true, - pathTaken: p ? [{ - lat: p.coords.latitude, - lng: p.coords.longitude, - time: new Date, - }] : [], - }); } \ No newline at end of file diff --git a/src/lib/state/helper.test.ts b/src/lib/state/helper.test.ts new file mode 100644 index 0000000..180564c --- /dev/null +++ b/src/lib/state/helper.test.ts @@ -0,0 +1,88 @@ +import type { ActiveTripSubscription } from '$lib/gira-api/ws-types'; +import { get } from 'svelte/store'; +import { updateWithTripMessage } from './helper'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { currentTrip } from '.'; +import { tripPayWithNoPoints, tripPayWithPoints } from '$lib/gira-api/__mocks__/api'; +import * as api from '$lib/gira-api/__mocks__/api'; + +beforeAll(() => { + vi.mock('$lib/gira-api/api'); +}); + +describe('updateWithTripMessage', () => { + it('should pay for the trip', () => { + const currentDate = new Date; + const activeTrip:ActiveTripSubscription = { + 'code': 'ABC123', + 'bike': 'E0041', + 'startDate': new Date(currentDate.getTime() - 2000), + 'endDate': null, + 'cost': 0, + 'finished': false, + 'canPayWithMoney': false, + 'canUsePoints': false, + 'clientPoints': 49170, + 'tripPoints': null, + 'canceled': false, + 'period': 'other', + 'periodTime': '116', + 'error': 0, + }; + global.Date.now = vi.fn(() => activeTrip.startDate.getTime() + 35000); + + const randomOldTrip:ActiveTripSubscription = { + 'code': '8PBL9FVCPH', + 'bike': 'E0040', + 'startDate': new Date(activeTrip.startDate.getTime() - 36000), + 'endDate': new Date(activeTrip.startDate.getTime() - 30000), + 'cost': 0, + 'finished': true, + 'canPayWithMoney': true, + 'canUsePoints': false, + 'clientPoints': 49170, + 'tripPoints': 110, + 'canceled': false, + 'period': 'other', + 'periodTime': '116', + 'error': 0, + }; + + const currentTripEnd:ActiveTripSubscription = { + 'code': 'ABC123', + 'bike': 'E0041', + 'startDate': new Date(currentDate.getTime() - 2000), + 'endDate': new Date, + 'cost': 0, + 'finished': true, + 'canPayWithMoney': true, + 'canUsePoints': false, + 'clientPoints': 49170, + 'tripPoints': 110, + 'canceled': false, + 'period': 'other', + 'periodTime': '116', + 'error': 0, + }; + + // set mock + + console.log(api); + vi.spyOn(api, 'tripPayWithNoPoints'); + vi.spyOn(api, 'tripPayWithPoints'); + updateWithTripMessage(activeTrip); + expect(tripPayWithPoints).toHaveBeenCalledTimes(0); + expect(tripPayWithNoPoints).toHaveBeenCalledTimes(0); + expect(get(currentTrip)?.code).toBe(activeTrip.code); + + updateWithTripMessage(randomOldTrip); + expect(tripPayWithPoints).toHaveBeenCalledTimes(0); + expect(tripPayWithNoPoints).toHaveBeenCalledTimes(1); + expect(get(currentTrip)?.code).toBe(activeTrip.code); + + updateWithTripMessage(currentTripEnd); + expect(tripPayWithPoints).toHaveBeenCalledTimes(0); + expect(tripPayWithNoPoints).toHaveBeenCalledTimes(2); + expect(get(currentTrip)).toBe(null); + }); +}); \ No newline at end of file diff --git a/src/lib/state/helper.ts b/src/lib/state/helper.ts new file mode 100644 index 0000000..f9fee78 --- /dev/null +++ b/src/lib/state/helper.ts @@ -0,0 +1,47 @@ +import { get } from 'svelte/store'; +import { token, userCredentials, accountInfo, currentTrip, user, selectedStation, tripRating } from '.'; +import { ingestAccountInfo, ingestActiveTripInfo, ingestCurrentTripUpdate, ingestLastUnratedTrip, ingestOtherTripUpdate, ingestStations, ingestSubscriptions } from './mutate'; +import type { ActiveTripSubscription } from '$lib/gira-api/ws-types'; +import { fullOnetimeInfo, getActiveTripInfo, tripPayWithNoPoints, tripPayWithPoints } from '$lib/gira-api/api'; + +export async function updateOnetimeInfo() { + const resp = await fullOnetimeInfo(); + ingestStations(resp); + ingestAccountInfo(resp); + ingestSubscriptions(resp); + ingestActiveTripInfo(resp); + ingestLastUnratedTrip(resp); +} + +export function updateActiveTripInfo() { + getActiveTripInfo().then(ingestActiveTripInfo); +} +export async function logOut() { + token.set(null); + userCredentials.set(null); + accountInfo.set(null); + currentTrip.set(null); + user.set(null); + selectedStation.set(null); + tripRating.set({ currentRating: null }); + // purposefully not settings settings distancelock, since thats annoying when you swap accounts +} + +export function updateWithTripMessage(recvTrip:ActiveTripSubscription) { + console.debug('ingesting trip message from ws', recvTrip); + if (recvTrip.code === 'no_trip' || recvTrip.bike === 'dummy') { + if (get(currentTrip)?.confirmed || Date.now() - (get(currentTrip)?.startDate?.getTime() ?? 0) > 30000) { + currentTrip.set(null); + } + return; + } + + if (recvTrip.finished) { + if (recvTrip.canUsePoints) tripPayWithPoints(recvTrip.code); + else if (recvTrip.canPayWithMoney) tripPayWithNoPoints(recvTrip.code); + } + + const ctrip = get(currentTrip); + if (recvTrip.code === ctrip?.code) ingestCurrentTripUpdate(recvTrip); + else ingestOtherTripUpdate(recvTrip); +} \ No newline at end of file diff --git a/src/lib/stores.ts b/src/lib/state/index.ts similarity index 89% rename from src/lib/stores.ts rename to src/lib/state/index.ts index dade1b8..868aff8 100644 --- a/src/lib/stores.ts +++ b/src/lib/state/index.ts @@ -1,10 +1,10 @@ import { Preferences } from '@capacitor/preferences'; import { get, writable, type Writable } from 'svelte/store'; -import { login, refreshToken, updateUserInfo } from './auth'; -import { updateOnetimeInfo } from './gira-api'; -import { startWS, ws } from './gira-api/ws'; -import { currentPos } from './location'; -import { distanceBetweenCoords } from './utils'; +import { login, refreshToken, updateUserInfo } from '../auth'; +import { currentPos } from '../location'; +import { distanceBetweenCoords } from '../utils'; +import { updateOnetimeInfo } from './helper'; +import { startWS } from '$lib/gira-api/ws'; export type User = { email: string; @@ -122,7 +122,7 @@ token.subscribe(async v => { if (!v) return; const jwt:JWT = JSON.parse(window.atob(v.accessToken.split('.')[1])); - if (!ws || ws.readyState === ws.CLOSED) startWS(); + startWS(); if (get(user) === null) { updateOnetimeInfo(); updateUserInfo(); @@ -179,15 +179,4 @@ currentPos.subscribe(async v => { } return trip; }); -}); - -export async function logOut() { - token.set(null); - userCredentials.set(null); - accountInfo.set(null); - currentTrip.set(null); - user.set(null); - selectedStation.set(null); - tripRating.set({ currentRating: null }); - // purposefully not settings settings distancelock, since thats annoying when you swap accounts -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/lib/state/mutate.ts b/src/lib/state/mutate.ts new file mode 100644 index 0000000..8e56263 --- /dev/null +++ b/src/lib/state/mutate.ts @@ -0,0 +1,191 @@ +import type { Q } from '$lib/gira-api'; +import { get } from 'svelte/store'; +import { stations, type StationInfo, tripRating, currentTrip, accountInfo } from '.'; +import type { ActiveTripSubscription } from '$lib/gira-api/ws-types'; +import { currentPos } from '$lib/location'; + +export function ingestStations(maybeStations:Q<['getStations']>) { + if (maybeStations.getStations === null || maybeStations.getStations === undefined) return; + const stationsList:StationInfo[] = []; + maybeStations.getStations.forEach(station => { + if (station === null || station === undefined) return; + const { code, name, description, latitude, longitude, bikes, docks, serialNumber, assetStatus } = station; + if (code === null || code === undefined || name === null || + name === undefined || description === undefined || + latitude === null || latitude === undefined || longitude === null || + longitude === undefined || bikes === null || bikes === undefined || + docks === null || docks === undefined || serialNumber === null || + serialNumber === undefined || assetStatus === null || assetStatus === undefined + ) { + console.error('invalid station', station); + return; + } + stationsList.push({ code, name, description, latitude, longitude, bikes, docks, serialNumber, assetStatus }); + }); + stations.set(stationsList); +} + +export function ingestAccountInfo(maybePointsAndBalance:Q<['client']>) { + if (maybePointsAndBalance.client === null || maybePointsAndBalance.client === undefined || maybePointsAndBalance.client.length <= 0) return; + const { balance, bonus } = maybePointsAndBalance.client[0]!; + if (balance === null || balance === undefined || bonus === null || bonus === undefined) return; + accountInfo.update(ai => ( + { subscription: ai?.subscription ?? null, balance, bonus } + )); +} + +export async function ingestSubscriptions(maybeSubscriptions:Q<['activeUserSubscriptions']>) { + if (maybeSubscriptions.activeUserSubscriptions === null || maybeSubscriptions.activeUserSubscriptions === undefined || maybeSubscriptions.activeUserSubscriptions.length <= 0) return; + const { active, expirationDate, name, subscriptionStatus, type } = maybeSubscriptions.activeUserSubscriptions[0]!; + accountInfo.update(ai => ( + { balance: ai?.balance ?? 0, bonus: ai?.bonus ?? 0, subscription: { active: active!, expirationDate: new Date(expirationDate), name: name!, subscriptionStatus: subscriptionStatus!, type: type ?? 'unknown' } } + )); +} + +export function ingestActiveTripInfo(maybeTrips:Q<['activeTrip']>) { + if (maybeTrips.activeTrip === null || maybeTrips.activeTrip === undefined || maybeTrips.activeTrip.code === 'no_trip' || maybeTrips.activeTrip.asset === 'dummy') { + if (get(currentTrip)?.confirmed || Date.now() - (get(currentTrip)?.startDate?.getTime() ?? 0) > 30000) { + currentTrip.set(null); + } + return; + } + const { + // asset, + startDate, + code, + // user, + // endDate, + // startLocation, + // endLocation, + // rating, + // photo, + // cost, + // startOccupation, + // endOccupation, + // totalBonus, + // client, + // costBonus, + // comment, + // compensationTime, + // endTripDock, + // tripStatus, + // name, + // description, + // creationDate, + // createdBy, + // updateDate, + // updatedBy, + // defaultOrder, + // version, + } = maybeTrips.activeTrip!; + currentTrip.update(ct => ct ? { + code: code!, + bikePlate: ct.bikePlate, + startPos: ct.startPos, + destination: ct.destination, + traveledDistanceKm: ct.traveledDistanceKm, + distanceLeft: ct.distanceLeft, + speed: ct.speed, + startDate: new Date(startDate!), + predictedEndDate: ct.predictedEndDate, + arrivalTime: ct.predictedEndDate, + finished: false, + confirmed: true, + pathTaken: ct.pathTaken, + } : { + code: code!, + bikePlate: null, + startPos: null, + destination: null, + traveledDistanceKm: 0, + distanceLeft: null, + speed: 0, + startDate: new Date(startDate!), + predictedEndDate: null, + arrivalTime: null, + finished: false, + confirmed: true, + pathTaken: [], + }); +} +export function ingestLastUnratedTrip(lastTripData:Q<['unratedTrips', 'tripHistory']>) { + if (lastTripData.unratedTrips === null || lastTripData.unratedTrips === undefined || lastTripData.unratedTrips.length <= 0) return; + const unratedTrip = lastTripData.unratedTrips[0]; + if (unratedTrip == null || unratedTrip.code == null || unratedTrip.asset == null) return; + const endToNow = (new Date).getTime() - new Date(unratedTrip.endDate).getTime(); + // check if 24h have passed + if (!(endToNow < 24 * 60 * 60 * 1000)) return; + let bikePlate; + if (lastTripData.tripHistory !== null && lastTripData.tripHistory !== undefined && lastTripData.tripHistory.length > 0) { + const lastTrip = lastTripData.tripHistory[0]; + if (lastTrip) { + const lastTripCode = lastTrip?.code; + bikePlate = lastTrip.bikeName; + if (lastTripCode !== unratedTrip.code) return; + } + } + + tripRating.set({ + currentRating: { + code: unratedTrip.code, + // probably have to translate asset to bike id + bikePlate: bikePlate ?? '???', + startDate: new Date(unratedTrip.startDate), + endDate: new Date(unratedTrip.endDate), + tripPoints: unratedTrip.costBonus || 0, + }, + }); +} + +export function ingestCurrentTripUpdate(recvTrip:ActiveTripSubscription) { + currentTrip.update(trip => { + // if trip finished, rate, else, update trip stuff + if (recvTrip.finished) { + currentTrip.set(null); + tripRating.update(rating => { + rating.currentRating = { + code: recvTrip.code, + bikePlate: recvTrip.bike, + startDate: new Date(recvTrip.startDate), + endDate: new Date(recvTrip.endDate ?? 0), + tripPoints: recvTrip.tripPoints ?? 0, + }; + return rating; + }); + return null; + } else { + if (trip === null) throw new Error('trip is null in impossible place'); + return { + ...trip, + startDate: new Date(recvTrip.startDate), + bikePlate: recvTrip.bike, + code: recvTrip.code, + confirmed: true, + }; + } + }); +} + +export function ingestOtherTripUpdate(recvTrip:ActiveTripSubscription) { + if (recvTrip.finished) return; + const p = get(currentPos); + currentTrip.set({ + startDate: new Date(recvTrip.startDate), + bikePlate: recvTrip.bike, + code: recvTrip.code, + finished: recvTrip.finished, + startPos: null, + destination: null, + traveledDistanceKm: 0, + distanceLeft: null, + speed: 0, + predictedEndDate: null, + arrivalTime: null, + confirmed: true, + pathTaken: p ? [{ + lat: p.coords.latitude, + lng: p.coords.longitude, + time: new Date, + }] : [], + }); +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8fe0c10..6a7d364 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -15,4 +15,11 @@ export function distanceBetweenCoords(lat1:number, lon1:number, lat2:number, lon export function formatDistance(distance:number) { if (distance < 1) return `${(distance * 1000).toFixed(0)}m`; return `${distance.toLocaleString(undefined, { maximumFractionDigits: 2, useGrouping: false })}km`; +} + +export function randomUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0, v = c == 'x' ? r : r & 0x3 | 0x8; + return v.toString(16); + }); } \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3fd0f78..a1774f5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,5 @@