From 3746853158de8c5f29e11bf97adbadb3cbd8618c Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Mon, 17 Nov 2025 16:58:27 +0200 Subject: [PATCH 01/10] chore: uplift to v1.0.0 --- CLAUDE.md | 2 +- app.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c47c3f..1b150db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Commute.ai is a React Native mobile application built with Expo and TypeScript that provides intelligent commute planning. The app is currently at version 0.8.0 and uses Expo SDK 54. +Commute.ai is a React Native mobile application built with Expo and TypeScript that provides intelligent commute planning. The app is currently at version 1.0.0 and uses Expo SDK 54. ## Development Commands diff --git a/app.json b/app.json index bb705ff..4669be9 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Commute AI", "slug": "commuteai-ui", - "version": "0.8.0", + "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/logo.png", "userInterfaceStyle": "automatic", diff --git a/package-lock.json b/package-lock.json index 1619c15..d94844a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "commuteai-ui", - "version": "0.8.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commuteai-ui", - "version": "0.8.0", + "version": "1.0.0", "dependencies": { "@radix-ui/react-label": "^2.1.7", "@react-navigation/native": "^7.1.8", diff --git a/package.json b/package.json index 0f104bc..aba97d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commuteai-ui", - "version": "0.8.0", + "version": "1.0.0", "description": "Commute.ai - Your intelligent commute companion", "main": "expo-router/entry", "scripts": { From db16e9be8e96f9458289d8d2911872363b9f268e Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Fri, 21 Nov 2025 12:10:17 +0200 Subject: [PATCH 02/10] docs: add an updated openapi.json --- assets/openapi.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 assets/openapi.json diff --git a/assets/openapi.json b/assets/openapi.json new file mode 100644 index 0000000..ff5de4e --- /dev/null +++ b/assets/openapi.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Commute.ai","description":"AI-powered public transport routing","version":"1.0.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"Health Check","description":"Comprehensive health check endpoint that verifies:\n- Database connectivity\n- HSL API availability\n- AI-agents API availability\n\nReturns 200 if all services are healthy, 503 if any service is down.","operationId":"health_check_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthCheckResponse"}}}}}}},"/api/v1/auth/register":{"post":{"tags":["auth"],"summary":"Register User","description":"Register a new user.","operationId":"register_user_api_v1_auth_register_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Token"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["auth"],"summary":"Login For Access Token","description":"OAuth2 compatible token login, get an access token for future requests.","operationId":"login_for_access_token_api_v1_auth_login_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_login_for_access_token_api_v1_auth_login_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Token"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/users/me":{"get":{"tags":["users"],"summary":"Read Current User","description":"Get current user.","operationId":"read_current_user_api_v1_users_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/v1/users/preferences":{"get":{"tags":["preferences"],"summary":"Get User Preferences","description":"Get all global preferences for the authenticated user.","operationId":"get_user_preferences_api_v1_users_preferences_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/GlobalPreferenceResponse"},"type":"array","title":"Response Get User Preferences Api V1 Users Preferences Get"}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["preferences"],"summary":"Create Preference","description":"Create a new global preference for the authenticated user.","operationId":"create_preference_api_v1_users_preferences_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalPreferenceCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalPreferenceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/v1/users/preferences/{preference_id}":{"delete":{"tags":["preferences"],"summary":"Delete Preference","description":"Delete a global preference for the authenticated user.","operationId":"delete_preference_api_v1_users_preferences__preference_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preference_id","in":"path","required":true,"schema":{"type":"integer","title":"Preference Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/users/route-preferences":{"get":{"tags":["route-preferences"],"summary":"Get User Route Preferences","description":"Get all route preferences for the authenticated user.","operationId":"get_user_route_preferences_api_v1_users_route_preferences_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/RoutePreferenceResponse"},"type":"array","title":"Response Get User Route Preferences Api V1 Users Route Preferences Get"}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["route-preferences"],"summary":"Create Route Preference","description":"Create a new route preference for the authenticated user.","operationId":"create_route_preference_api_v1_users_route_preferences_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoutePreferenceCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoutePreferenceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/v1/users/route-preferences/{preference_id}":{"delete":{"tags":["route-preferences"],"summary":"Delete Route Preference","description":"Delete a route preference for the authenticated user.","operationId":"delete_route_preference_api_v1_users_route_preferences__preference_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preference_id","in":"path","required":true,"schema":{"type":"integer","title":"Preference Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/routes/search":{"post":{"tags":["routes"],"summary":"Search Routes","description":"Search for public transport routes between two locations.\n\nQueries the HSL (Helsinki Regional Transport) API to find route alternatives\nbetween origin and destination coordinates. Requires authentication.\n\nArgs:\n request: Route search parameters including origin, destination, and preferences\n db: Database session\n current_user: Authenticated user (required)\n\nReturns:\n RouteSearchResponse with list of available route itineraries\n\nRaises:\n HTTPException: If the route search fails or returns invalid data","operationId":"search_routes_api_v1_routes_search_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteSearchRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/":{"get":{"summary":"Read Root","operationId":"read_root__get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"Body_login_for_access_token_api_v1_auth_login_post":{"properties":{"grant_type":{"anyOf":[{"type":"string","pattern":"^password$"},{"type":"null"}],"title":"Grant Type"},"username":{"type":"string","title":"Username"},"password":{"type":"string","format":"password","title":"Password"},"scope":{"type":"string","title":"Scope","default":""},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"password","title":"Client Secret"}},"type":"object","required":["username","password"],"title":"Body_login_for_access_token_api_v1_auth_login_post"},"Coordinates":{"properties":{"latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"Latitude","description":"Latitude in decimal degrees"},"longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"Longitude","description":"Longitude in decimal degrees"}},"type":"object","required":["latitude","longitude"],"title":"Coordinates","description":"Geographic coordinates (latitude and longitude)."},"GlobalPreferenceCreate":{"properties":{"prompt":{"type":"string","title":"Prompt"}},"type":"object","required":["prompt"],"title":"GlobalPreferenceCreate"},"GlobalPreferenceResponse":{"properties":{"prompt":{"type":"string","title":"Prompt"},"id":{"type":"integer","title":"Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["prompt","id","created_at"],"title":"GlobalPreferenceResponse"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthCheckResponse":{"properties":{"service":{"type":"string","title":"Service"},"version":{"type":"string","title":"Version"},"timestamp":{"type":"string","title":"Timestamp"},"healthy":{"type":"boolean","title":"Healthy"},"database":{"$ref":"#/components/schemas/ServiceHealth"},"routing_service":{"$ref":"#/components/schemas/ServiceHealth"},"ai_agents_service":{"$ref":"#/components/schemas/ServiceHealth"}},"type":"object","required":["service","version","timestamp","healthy","database","routing_service","ai_agents_service"],"title":"HealthCheckResponse"},"Itinerary":{"properties":{"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Total duration in seconds"},"walk_distance":{"type":"number","title":"Walk Distance","description":"Total walking distance in meters"},"walk_time":{"type":"integer","title":"Walk Time","description":"Total walking time in seconds"},"legs":{"items":{"$ref":"#/components/schemas/Leg"},"type":"array","title":"Legs"}},"type":"object","required":["start","end","duration","walk_distance","walk_time","legs"],"title":"Itinerary","description":"A complete journey from origin to destination."},"ItineraryWithInsight":{"properties":{"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Total duration in seconds"},"walk_distance":{"type":"number","title":"Walk Distance","description":"Total walking distance in meters"},"walk_time":{"type":"integer","title":"Walk Time","description":"Total walking time in seconds"},"legs":{"items":{"$ref":"#/components/schemas/LegWithInsight"},"type":"array","title":"Legs"},"ai_insight":{"type":"string","title":"Ai Insight","description":"AI-generated insight about the itinerary"}},"type":"object","required":["start","end","duration","walk_distance","walk_time","legs","ai_insight"],"title":"ItineraryWithInsight","description":"A complete journey from origin to destination with AI insights."},"Leg":{"properties":{"mode":{"$ref":"#/components/schemas/TransportMode"},"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Duration in seconds"},"distance":{"type":"number","title":"Distance","description":"Distance in meters"},"from_place":{"$ref":"#/components/schemas/Place"},"to_place":{"$ref":"#/components/schemas/Place"},"route":{"anyOf":[{"$ref":"#/components/schemas/Route"},{"type":"null"}]}},"type":"object","required":["mode","start","end","duration","distance","from_place","to_place"],"title":"Leg","description":"A single segment of a journey."},"LegWithInsight":{"properties":{"mode":{"$ref":"#/components/schemas/TransportMode"},"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Duration in seconds"},"distance":{"type":"number","title":"Distance","description":"Distance in meters"},"from_place":{"$ref":"#/components/schemas/Place"},"to_place":{"$ref":"#/components/schemas/Place"},"route":{"anyOf":[{"$ref":"#/components/schemas/Route"},{"type":"null"}]},"ai_insight":{"type":"string","title":"Ai Insight","description":"AI-generated insight about the leg"}},"type":"object","required":["mode","start","end","duration","distance","from_place","to_place","ai_insight"],"title":"LegWithInsight","description":"A single segment of a journey with AI insight."},"Place":{"properties":{"coordinates":{"$ref":"#/components/schemas/Coordinates"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["coordinates"],"title":"Place","description":"A place with metadata"},"Route":{"properties":{"short_name":{"type":"string","title":"Short Name","description":"Short name of the route, e.g., bus number"},"long_name":{"type":"string","title":"Long Name","description":"Long name of the route, e.g., full route name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description of the route"}},"type":"object","required":["short_name","long_name"],"title":"Route","description":"Route information for a leg of the journey."},"RoutePreferenceCreate":{"properties":{"prompt":{"type":"string","title":"Prompt"},"from_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"From Latitude"},"from_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"From Longitude"},"to_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"To Latitude"},"to_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"To Longitude"}},"type":"object","required":["prompt","from_latitude","from_longitude","to_latitude","to_longitude"],"title":"RoutePreferenceCreate"},"RoutePreferenceResponse":{"properties":{"prompt":{"type":"string","title":"Prompt"},"from_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"From Latitude"},"from_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"From Longitude"},"to_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"To Latitude"},"to_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"To Longitude"},"id":{"type":"integer","title":"Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["prompt","from_latitude","from_longitude","to_latitude","to_longitude","id","created_at"],"title":"RoutePreferenceResponse"},"RouteSearchRequest":{"properties":{"origin":{"$ref":"#/components/schemas/Coordinates","description":"Starting location coordinates"},"destination":{"$ref":"#/components/schemas/Coordinates","description":"Destination location coordinates"},"earliest_departure":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Earliest Departure","description":"Earliest departure time (ISO format). Defaults to current time if not provided."},"num_itineraries":{"type":"integer","maximum":10.0,"minimum":1.0,"title":"Num Itineraries","description":"Number of route alternatives to return (1-10, default: 3)","default":3},"preferences":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Preferences","description":"User preferences for route optimization (e.g., 'prefer walking', 'avoid buses')"}},"type":"object","required":["origin","destination"],"title":"RouteSearchRequest","description":"Request schema for route search endpoint."},"RouteSearchResponse":{"properties":{"origin":{"$ref":"#/components/schemas/Coordinates"},"destination":{"$ref":"#/components/schemas/Coordinates"},"itineraries":{"items":{"anyOf":[{"$ref":"#/components/schemas/Itinerary"},{"$ref":"#/components/schemas/ItineraryWithInsight"}]},"type":"array","title":"Itineraries","description":"List of route itineraries"},"search_time":{"type":"string","format":"date-time","title":"Search Time","description":"Time when the search was performed"}},"type":"object","required":["origin","destination","itineraries","search_time"],"title":"RouteSearchResponse","description":"Response schema for route search endpoint."},"ServiceHealth":{"properties":{"healthy":{"type":"boolean","title":"Healthy"},"message":{"type":"string","title":"Message"}},"type":"object","required":["healthy","message"],"title":"ServiceHealth"},"Token":{"properties":{"access_token":{"type":"string","title":"Access Token"},"token_type":{"type":"string","title":"Token Type"}},"type":"object","required":["access_token","token_type"],"title":"Token"},"TransportMode":{"type":"string","enum":["WALK","BICYCLE","CAR","TRAM","SUBWAY","RAIL","BUS","FERRY"],"title":"TransportMode","description":"Transport modes."},"UserCreate":{"properties":{"username":{"type":"string","title":"Username"},"password":{"type":"string","title":"Password"}},"type":"object","required":["username","password"],"title":"UserCreate"},"UserResponse":{"properties":{"username":{"type":"string","title":"Username"},"id":{"type":"integer","title":"Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["username","id","created_at"],"title":"UserResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{},"tokenUrl":"/api/v1/auth/login"}}}}}} \ No newline at end of file From fad9b7708653d8b9d07974018a8cbf0f9b1ce6b5 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Fri, 21 Nov 2025 12:44:29 +0200 Subject: [PATCH 03/10] refactor(preferences): move the preference types to types folder --- src/types/preferences.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/types/preferences.ts diff --git a/src/types/preferences.ts b/src/types/preferences.ts new file mode 100644 index 0000000..8c2e0d3 --- /dev/null +++ b/src/types/preferences.ts @@ -0,0 +1,19 @@ +import z from "zod"; + +import { PlaceSchema } from "./location"; + +export const PreferenceSchema = z.object({ + id: z.number(), + prompt: z.string(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable().optional(), +}); + +export const RoutePreferencesSchema = z.object({ + from: PlaceSchema, + to: PlaceSchema, + preferences: z.array(PreferenceSchema), +}); + +export type Preference = z.infer; +export type RoutePreferences = z.infer; From 8ae291fdb28ce9d7a00c1f313747ec9b4c44d7ec Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Fri, 21 Nov 2025 12:52:10 +0200 Subject: [PATCH 04/10] styles: format openapi.json --- assets/openapi.json | 1004 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1003 insertions(+), 1 deletion(-) diff --git a/assets/openapi.json b/assets/openapi.json index ff5de4e..dfbc099 100644 --- a/assets/openapi.json +++ b/assets/openapi.json @@ -1 +1,1003 @@ -{"openapi":"3.1.0","info":{"title":"Commute.ai","description":"AI-powered public transport routing","version":"1.0.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"Health Check","description":"Comprehensive health check endpoint that verifies:\n- Database connectivity\n- HSL API availability\n- AI-agents API availability\n\nReturns 200 if all services are healthy, 503 if any service is down.","operationId":"health_check_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthCheckResponse"}}}}}}},"/api/v1/auth/register":{"post":{"tags":["auth"],"summary":"Register User","description":"Register a new user.","operationId":"register_user_api_v1_auth_register_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Token"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["auth"],"summary":"Login For Access Token","description":"OAuth2 compatible token login, get an access token for future requests.","operationId":"login_for_access_token_api_v1_auth_login_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_login_for_access_token_api_v1_auth_login_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Token"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/users/me":{"get":{"tags":["users"],"summary":"Read Current User","description":"Get current user.","operationId":"read_current_user_api_v1_users_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/v1/users/preferences":{"get":{"tags":["preferences"],"summary":"Get User Preferences","description":"Get all global preferences for the authenticated user.","operationId":"get_user_preferences_api_v1_users_preferences_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/GlobalPreferenceResponse"},"type":"array","title":"Response Get User Preferences Api V1 Users Preferences Get"}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["preferences"],"summary":"Create Preference","description":"Create a new global preference for the authenticated user.","operationId":"create_preference_api_v1_users_preferences_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalPreferenceCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalPreferenceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/v1/users/preferences/{preference_id}":{"delete":{"tags":["preferences"],"summary":"Delete Preference","description":"Delete a global preference for the authenticated user.","operationId":"delete_preference_api_v1_users_preferences__preference_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preference_id","in":"path","required":true,"schema":{"type":"integer","title":"Preference Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/users/route-preferences":{"get":{"tags":["route-preferences"],"summary":"Get User Route Preferences","description":"Get all route preferences for the authenticated user.","operationId":"get_user_route_preferences_api_v1_users_route_preferences_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/RoutePreferenceResponse"},"type":"array","title":"Response Get User Route Preferences Api V1 Users Route Preferences Get"}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"post":{"tags":["route-preferences"],"summary":"Create Route Preference","description":"Create a new route preference for the authenticated user.","operationId":"create_route_preference_api_v1_users_route_preferences_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoutePreferenceCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoutePreferenceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/v1/users/route-preferences/{preference_id}":{"delete":{"tags":["route-preferences"],"summary":"Delete Route Preference","description":"Delete a route preference for the authenticated user.","operationId":"delete_route_preference_api_v1_users_route_preferences__preference_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preference_id","in":"path","required":true,"schema":{"type":"integer","title":"Preference Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/routes/search":{"post":{"tags":["routes"],"summary":"Search Routes","description":"Search for public transport routes between two locations.\n\nQueries the HSL (Helsinki Regional Transport) API to find route alternatives\nbetween origin and destination coordinates. Requires authentication.\n\nArgs:\n request: Route search parameters including origin, destination, and preferences\n db: Database session\n current_user: Authenticated user (required)\n\nReturns:\n RouteSearchResponse with list of available route itineraries\n\nRaises:\n HTTPException: If the route search fails or returns invalid data","operationId":"search_routes_api_v1_routes_search_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteSearchRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/":{"get":{"summary":"Read Root","operationId":"read_root__get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"Body_login_for_access_token_api_v1_auth_login_post":{"properties":{"grant_type":{"anyOf":[{"type":"string","pattern":"^password$"},{"type":"null"}],"title":"Grant Type"},"username":{"type":"string","title":"Username"},"password":{"type":"string","format":"password","title":"Password"},"scope":{"type":"string","title":"Scope","default":""},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"password","title":"Client Secret"}},"type":"object","required":["username","password"],"title":"Body_login_for_access_token_api_v1_auth_login_post"},"Coordinates":{"properties":{"latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"Latitude","description":"Latitude in decimal degrees"},"longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"Longitude","description":"Longitude in decimal degrees"}},"type":"object","required":["latitude","longitude"],"title":"Coordinates","description":"Geographic coordinates (latitude and longitude)."},"GlobalPreferenceCreate":{"properties":{"prompt":{"type":"string","title":"Prompt"}},"type":"object","required":["prompt"],"title":"GlobalPreferenceCreate"},"GlobalPreferenceResponse":{"properties":{"prompt":{"type":"string","title":"Prompt"},"id":{"type":"integer","title":"Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["prompt","id","created_at"],"title":"GlobalPreferenceResponse"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthCheckResponse":{"properties":{"service":{"type":"string","title":"Service"},"version":{"type":"string","title":"Version"},"timestamp":{"type":"string","title":"Timestamp"},"healthy":{"type":"boolean","title":"Healthy"},"database":{"$ref":"#/components/schemas/ServiceHealth"},"routing_service":{"$ref":"#/components/schemas/ServiceHealth"},"ai_agents_service":{"$ref":"#/components/schemas/ServiceHealth"}},"type":"object","required":["service","version","timestamp","healthy","database","routing_service","ai_agents_service"],"title":"HealthCheckResponse"},"Itinerary":{"properties":{"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Total duration in seconds"},"walk_distance":{"type":"number","title":"Walk Distance","description":"Total walking distance in meters"},"walk_time":{"type":"integer","title":"Walk Time","description":"Total walking time in seconds"},"legs":{"items":{"$ref":"#/components/schemas/Leg"},"type":"array","title":"Legs"}},"type":"object","required":["start","end","duration","walk_distance","walk_time","legs"],"title":"Itinerary","description":"A complete journey from origin to destination."},"ItineraryWithInsight":{"properties":{"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Total duration in seconds"},"walk_distance":{"type":"number","title":"Walk Distance","description":"Total walking distance in meters"},"walk_time":{"type":"integer","title":"Walk Time","description":"Total walking time in seconds"},"legs":{"items":{"$ref":"#/components/schemas/LegWithInsight"},"type":"array","title":"Legs"},"ai_insight":{"type":"string","title":"Ai Insight","description":"AI-generated insight about the itinerary"}},"type":"object","required":["start","end","duration","walk_distance","walk_time","legs","ai_insight"],"title":"ItineraryWithInsight","description":"A complete journey from origin to destination with AI insights."},"Leg":{"properties":{"mode":{"$ref":"#/components/schemas/TransportMode"},"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Duration in seconds"},"distance":{"type":"number","title":"Distance","description":"Distance in meters"},"from_place":{"$ref":"#/components/schemas/Place"},"to_place":{"$ref":"#/components/schemas/Place"},"route":{"anyOf":[{"$ref":"#/components/schemas/Route"},{"type":"null"}]}},"type":"object","required":["mode","start","end","duration","distance","from_place","to_place"],"title":"Leg","description":"A single segment of a journey."},"LegWithInsight":{"properties":{"mode":{"$ref":"#/components/schemas/TransportMode"},"start":{"type":"string","format":"date-time","title":"Start"},"end":{"type":"string","format":"date-time","title":"End"},"duration":{"type":"integer","title":"Duration","description":"Duration in seconds"},"distance":{"type":"number","title":"Distance","description":"Distance in meters"},"from_place":{"$ref":"#/components/schemas/Place"},"to_place":{"$ref":"#/components/schemas/Place"},"route":{"anyOf":[{"$ref":"#/components/schemas/Route"},{"type":"null"}]},"ai_insight":{"type":"string","title":"Ai Insight","description":"AI-generated insight about the leg"}},"type":"object","required":["mode","start","end","duration","distance","from_place","to_place","ai_insight"],"title":"LegWithInsight","description":"A single segment of a journey with AI insight."},"Place":{"properties":{"coordinates":{"$ref":"#/components/schemas/Coordinates"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["coordinates"],"title":"Place","description":"A place with metadata"},"Route":{"properties":{"short_name":{"type":"string","title":"Short Name","description":"Short name of the route, e.g., bus number"},"long_name":{"type":"string","title":"Long Name","description":"Long name of the route, e.g., full route name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description of the route"}},"type":"object","required":["short_name","long_name"],"title":"Route","description":"Route information for a leg of the journey."},"RoutePreferenceCreate":{"properties":{"prompt":{"type":"string","title":"Prompt"},"from_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"From Latitude"},"from_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"From Longitude"},"to_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"To Latitude"},"to_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"To Longitude"}},"type":"object","required":["prompt","from_latitude","from_longitude","to_latitude","to_longitude"],"title":"RoutePreferenceCreate"},"RoutePreferenceResponse":{"properties":{"prompt":{"type":"string","title":"Prompt"},"from_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"From Latitude"},"from_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"From Longitude"},"to_latitude":{"type":"number","maximum":90.0,"minimum":-90.0,"title":"To Latitude"},"to_longitude":{"type":"number","maximum":180.0,"minimum":-180.0,"title":"To Longitude"},"id":{"type":"integer","title":"Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["prompt","from_latitude","from_longitude","to_latitude","to_longitude","id","created_at"],"title":"RoutePreferenceResponse"},"RouteSearchRequest":{"properties":{"origin":{"$ref":"#/components/schemas/Coordinates","description":"Starting location coordinates"},"destination":{"$ref":"#/components/schemas/Coordinates","description":"Destination location coordinates"},"earliest_departure":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Earliest Departure","description":"Earliest departure time (ISO format). Defaults to current time if not provided."},"num_itineraries":{"type":"integer","maximum":10.0,"minimum":1.0,"title":"Num Itineraries","description":"Number of route alternatives to return (1-10, default: 3)","default":3},"preferences":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Preferences","description":"User preferences for route optimization (e.g., 'prefer walking', 'avoid buses')"}},"type":"object","required":["origin","destination"],"title":"RouteSearchRequest","description":"Request schema for route search endpoint."},"RouteSearchResponse":{"properties":{"origin":{"$ref":"#/components/schemas/Coordinates"},"destination":{"$ref":"#/components/schemas/Coordinates"},"itineraries":{"items":{"anyOf":[{"$ref":"#/components/schemas/Itinerary"},{"$ref":"#/components/schemas/ItineraryWithInsight"}]},"type":"array","title":"Itineraries","description":"List of route itineraries"},"search_time":{"type":"string","format":"date-time","title":"Search Time","description":"Time when the search was performed"}},"type":"object","required":["origin","destination","itineraries","search_time"],"title":"RouteSearchResponse","description":"Response schema for route search endpoint."},"ServiceHealth":{"properties":{"healthy":{"type":"boolean","title":"Healthy"},"message":{"type":"string","title":"Message"}},"type":"object","required":["healthy","message"],"title":"ServiceHealth"},"Token":{"properties":{"access_token":{"type":"string","title":"Access Token"},"token_type":{"type":"string","title":"Token Type"}},"type":"object","required":["access_token","token_type"],"title":"Token"},"TransportMode":{"type":"string","enum":["WALK","BICYCLE","CAR","TRAM","SUBWAY","RAIL","BUS","FERRY"],"title":"TransportMode","description":"Transport modes."},"UserCreate":{"properties":{"username":{"type":"string","title":"Username"},"password":{"type":"string","title":"Password"}},"type":"object","required":["username","password"],"title":"UserCreate"},"UserResponse":{"properties":{"username":{"type":"string","title":"Username"},"id":{"type":"integer","title":"Id"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["username","id","created_at"],"title":"UserResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{},"tokenUrl":"/api/v1/auth/login"}}}}}} \ No newline at end of file +{ + "openapi": "3.1.0", + "info": { + "title": "Commute.ai", + "description": "AI-powered public transport routing", + "version": "1.0.0" + }, + "paths": { + "/api/v1/health": { + "get": { + "tags": ["health"], + "summary": "Health Check", + "description": "Comprehensive health check endpoint that verifies:\n- Database connectivity\n- HSL API availability\n- AI-agents API availability\n\nReturns 200 if all services are healthy, 503 if any service is down.", + "operationId": "health_check_api_v1_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthCheckResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "tags": ["auth"], + "summary": "Register User", + "description": "Register a new user.", + "operationId": "register_user_api_v1_auth_register_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": ["auth"], + "summary": "Login For Access Token", + "description": "OAuth2 compatible token login, get an access token for future requests.", + "operationId": "login_for_access_token_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_login_for_access_token_api_v1_auth_login_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/users/me": { + "get": { + "tags": ["users"], + "summary": "Read Current User", + "description": "Get current user.", + "operationId": "read_current_user_api_v1_users_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + } + }, + "security": [{ "OAuth2PasswordBearer": [] }] + } + }, + "/api/v1/users/preferences": { + "get": { + "tags": ["preferences"], + "summary": "Get User Preferences", + "description": "Get all global preferences for the authenticated user.", + "operationId": "get_user_preferences_api_v1_users_preferences_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/GlobalPreferenceResponse" + }, + "type": "array", + "title": "Response Get User Preferences Api V1 Users Preferences Get" + } + } + } + } + }, + "security": [{ "OAuth2PasswordBearer": [] }] + }, + "post": { + "tags": ["preferences"], + "summary": "Create Preference", + "description": "Create a new global preference for the authenticated user.", + "operationId": "create_preference_api_v1_users_preferences_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalPreferenceCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalPreferenceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [{ "OAuth2PasswordBearer": [] }] + } + }, + "/api/v1/users/preferences/{preference_id}": { + "delete": { + "tags": ["preferences"], + "summary": "Delete Preference", + "description": "Delete a global preference for the authenticated user.", + "operationId": "delete_preference_api_v1_users_preferences__preference_id__delete", + "security": [{ "OAuth2PasswordBearer": [] }], + "parameters": [ + { + "name": "preference_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Preference Id" + } + } + ], + "responses": { + "204": { "description": "Successful Response" }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/users/route-preferences": { + "get": { + "tags": ["route-preferences"], + "summary": "Get User Route Preferences", + "description": "Get all route preferences for the authenticated user.", + "operationId": "get_user_route_preferences_api_v1_users_route_preferences_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RoutePreferenceResponse" + }, + "type": "array", + "title": "Response Get User Route Preferences Api V1 Users Route Preferences Get" + } + } + } + } + }, + "security": [{ "OAuth2PasswordBearer": [] }] + }, + "post": { + "tags": ["route-preferences"], + "summary": "Create Route Preference", + "description": "Create a new route preference for the authenticated user.", + "operationId": "create_route_preference_api_v1_users_route_preferences_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoutePreferenceCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoutePreferenceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [{ "OAuth2PasswordBearer": [] }] + } + }, + "/api/v1/users/route-preferences/{preference_id}": { + "delete": { + "tags": ["route-preferences"], + "summary": "Delete Route Preference", + "description": "Delete a route preference for the authenticated user.", + "operationId": "delete_route_preference_api_v1_users_route_preferences__preference_id__delete", + "security": [{ "OAuth2PasswordBearer": [] }], + "parameters": [ + { + "name": "preference_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Preference Id" + } + } + ], + "responses": { + "204": { "description": "Successful Response" }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/routes/search": { + "post": { + "tags": ["routes"], + "summary": "Search Routes", + "description": "Search for public transport routes between two locations.\n\nQueries the HSL (Helsinki Regional Transport) API to find route alternatives\nbetween origin and destination coordinates. Requires authentication.\n\nArgs:\n request: Route search parameters including origin, destination, and preferences\n db: Database session\n current_user: Authenticated user (required)\n\nReturns:\n RouteSearchResponse with list of available route itineraries\n\nRaises:\n HTTPException: If the route search fails or returns invalid data", + "operationId": "search_routes_api_v1_routes_search_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouteSearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouteSearchResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [{ "OAuth2PasswordBearer": [] }] + } + }, + "/": { + "get": { + "summary": "Read Root", + "operationId": "read_root__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { "application/json": { "schema": {} } } + } + } + } + } + }, + "components": { + "schemas": { + "Body_login_for_access_token_api_v1_auth_login_post": { + "properties": { + "grant_type": { + "anyOf": [ + { "type": "string", "pattern": "^password$" }, + { "type": "null" } + ], + "title": "Grant Type" + }, + "username": { "type": "string", "title": "Username" }, + "password": { + "type": "string", + "format": "password", + "title": "Password" + }, + "scope": { + "type": "string", + "title": "Scope", + "default": "" + }, + "client_id": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Client Id" + }, + "client_secret": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "format": "password", + "title": "Client Secret" + } + }, + "type": "object", + "required": ["username", "password"], + "title": "Body_login_for_access_token_api_v1_auth_login_post" + }, + "Coordinates": { + "properties": { + "latitude": { + "type": "number", + "maximum": 90.0, + "minimum": -90.0, + "title": "Latitude", + "description": "Latitude in decimal degrees" + }, + "longitude": { + "type": "number", + "maximum": 180.0, + "minimum": -180.0, + "title": "Longitude", + "description": "Longitude in decimal degrees" + } + }, + "type": "object", + "required": ["latitude", "longitude"], + "title": "Coordinates", + "description": "Geographic coordinates (latitude and longitude)." + }, + "GlobalPreferenceCreate": { + "properties": { + "prompt": { "type": "string", "title": "Prompt" } + }, + "type": "object", + "required": ["prompt"], + "title": "GlobalPreferenceCreate" + }, + "GlobalPreferenceResponse": { + "properties": { + "prompt": { "type": "string", "title": "Prompt" }, + "id": { "type": "integer", "title": "Id" }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": ["prompt", "id", "created_at"], + "title": "GlobalPreferenceResponse" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HealthCheckResponse": { + "properties": { + "service": { "type": "string", "title": "Service" }, + "version": { "type": "string", "title": "Version" }, + "timestamp": { "type": "string", "title": "Timestamp" }, + "healthy": { "type": "boolean", "title": "Healthy" }, + "database": { + "$ref": "#/components/schemas/ServiceHealth" + }, + "routing_service": { + "$ref": "#/components/schemas/ServiceHealth" + }, + "ai_agents_service": { + "$ref": "#/components/schemas/ServiceHealth" + } + }, + "type": "object", + "required": [ + "service", + "version", + "timestamp", + "healthy", + "database", + "routing_service", + "ai_agents_service" + ], + "title": "HealthCheckResponse" + }, + "Itinerary": { + "properties": { + "start": { + "type": "string", + "format": "date-time", + "title": "Start" + }, + "end": { + "type": "string", + "format": "date-time", + "title": "End" + }, + "duration": { + "type": "integer", + "title": "Duration", + "description": "Total duration in seconds" + }, + "walk_distance": { + "type": "number", + "title": "Walk Distance", + "description": "Total walking distance in meters" + }, + "walk_time": { + "type": "integer", + "title": "Walk Time", + "description": "Total walking time in seconds" + }, + "legs": { + "items": { "$ref": "#/components/schemas/Leg" }, + "type": "array", + "title": "Legs" + } + }, + "type": "object", + "required": [ + "start", + "end", + "duration", + "walk_distance", + "walk_time", + "legs" + ], + "title": "Itinerary", + "description": "A complete journey from origin to destination." + }, + "ItineraryWithInsight": { + "properties": { + "start": { + "type": "string", + "format": "date-time", + "title": "Start" + }, + "end": { + "type": "string", + "format": "date-time", + "title": "End" + }, + "duration": { + "type": "integer", + "title": "Duration", + "description": "Total duration in seconds" + }, + "walk_distance": { + "type": "number", + "title": "Walk Distance", + "description": "Total walking distance in meters" + }, + "walk_time": { + "type": "integer", + "title": "Walk Time", + "description": "Total walking time in seconds" + }, + "legs": { + "items": { + "$ref": "#/components/schemas/LegWithInsight" + }, + "type": "array", + "title": "Legs" + }, + "ai_insight": { + "type": "string", + "title": "Ai Insight", + "description": "AI-generated insight about the itinerary" + } + }, + "type": "object", + "required": [ + "start", + "end", + "duration", + "walk_distance", + "walk_time", + "legs", + "ai_insight" + ], + "title": "ItineraryWithInsight", + "description": "A complete journey from origin to destination with AI insights." + }, + "Leg": { + "properties": { + "mode": { "$ref": "#/components/schemas/TransportMode" }, + "start": { + "type": "string", + "format": "date-time", + "title": "Start" + }, + "end": { + "type": "string", + "format": "date-time", + "title": "End" + }, + "duration": { + "type": "integer", + "title": "Duration", + "description": "Duration in seconds" + }, + "distance": { + "type": "number", + "title": "Distance", + "description": "Distance in meters" + }, + "from_place": { "$ref": "#/components/schemas/Place" }, + "to_place": { "$ref": "#/components/schemas/Place" }, + "route": { + "anyOf": [ + { "$ref": "#/components/schemas/Route" }, + { "type": "null" } + ] + } + }, + "type": "object", + "required": [ + "mode", + "start", + "end", + "duration", + "distance", + "from_place", + "to_place" + ], + "title": "Leg", + "description": "A single segment of a journey." + }, + "LegWithInsight": { + "properties": { + "mode": { "$ref": "#/components/schemas/TransportMode" }, + "start": { + "type": "string", + "format": "date-time", + "title": "Start" + }, + "end": { + "type": "string", + "format": "date-time", + "title": "End" + }, + "duration": { + "type": "integer", + "title": "Duration", + "description": "Duration in seconds" + }, + "distance": { + "type": "number", + "title": "Distance", + "description": "Distance in meters" + }, + "from_place": { "$ref": "#/components/schemas/Place" }, + "to_place": { "$ref": "#/components/schemas/Place" }, + "route": { + "anyOf": [ + { "$ref": "#/components/schemas/Route" }, + { "type": "null" } + ] + }, + "ai_insight": { + "type": "string", + "title": "Ai Insight", + "description": "AI-generated insight about the leg" + } + }, + "type": "object", + "required": [ + "mode", + "start", + "end", + "duration", + "distance", + "from_place", + "to_place", + "ai_insight" + ], + "title": "LegWithInsight", + "description": "A single segment of a journey with AI insight." + }, + "Place": { + "properties": { + "coordinates": { + "$ref": "#/components/schemas/Coordinates" + }, + "name": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Name" + } + }, + "type": "object", + "required": ["coordinates"], + "title": "Place", + "description": "A place with metadata" + }, + "Route": { + "properties": { + "short_name": { + "type": "string", + "title": "Short Name", + "description": "Short name of the route, e.g., bus number" + }, + "long_name": { + "type": "string", + "title": "Long Name", + "description": "Long name of the route, e.g., full route name" + }, + "description": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Description", + "description": "Description of the route" + } + }, + "type": "object", + "required": ["short_name", "long_name"], + "title": "Route", + "description": "Route information for a leg of the journey." + }, + "RoutePreferenceCreate": { + "properties": { + "prompt": { "type": "string", "title": "Prompt" }, + "from_latitude": { + "type": "number", + "maximum": 90.0, + "minimum": -90.0, + "title": "From Latitude" + }, + "from_longitude": { + "type": "number", + "maximum": 180.0, + "minimum": -180.0, + "title": "From Longitude" + }, + "to_latitude": { + "type": "number", + "maximum": 90.0, + "minimum": -90.0, + "title": "To Latitude" + }, + "to_longitude": { + "type": "number", + "maximum": 180.0, + "minimum": -180.0, + "title": "To Longitude" + } + }, + "type": "object", + "required": [ + "prompt", + "from_latitude", + "from_longitude", + "to_latitude", + "to_longitude" + ], + "title": "RoutePreferenceCreate" + }, + "RoutePreferenceResponse": { + "properties": { + "prompt": { "type": "string", "title": "Prompt" }, + "from_latitude": { + "type": "number", + "maximum": 90.0, + "minimum": -90.0, + "title": "From Latitude" + }, + "from_longitude": { + "type": "number", + "maximum": 180.0, + "minimum": -180.0, + "title": "From Longitude" + }, + "to_latitude": { + "type": "number", + "maximum": 90.0, + "minimum": -90.0, + "title": "To Latitude" + }, + "to_longitude": { + "type": "number", + "maximum": 180.0, + "minimum": -180.0, + "title": "To Longitude" + }, + "id": { "type": "integer", "title": "Id" }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "prompt", + "from_latitude", + "from_longitude", + "to_latitude", + "to_longitude", + "id", + "created_at" + ], + "title": "RoutePreferenceResponse" + }, + "RouteSearchRequest": { + "properties": { + "origin": { + "$ref": "#/components/schemas/Coordinates", + "description": "Starting location coordinates" + }, + "destination": { + "$ref": "#/components/schemas/Coordinates", + "description": "Destination location coordinates" + }, + "earliest_departure": { + "anyOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ], + "title": "Earliest Departure", + "description": "Earliest departure time (ISO format). Defaults to current time if not provided." + }, + "num_itineraries": { + "type": "integer", + "maximum": 10.0, + "minimum": 1.0, + "title": "Num Itineraries", + "description": "Number of route alternatives to return (1-10, default: 3)", + "default": 3 + }, + "preferences": { + "anyOf": [ + { "items": { "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "title": "Preferences", + "description": "User preferences for route optimization (e.g., 'prefer walking', 'avoid buses')" + } + }, + "type": "object", + "required": ["origin", "destination"], + "title": "RouteSearchRequest", + "description": "Request schema for route search endpoint." + }, + "RouteSearchResponse": { + "properties": { + "origin": { "$ref": "#/components/schemas/Coordinates" }, + "destination": { + "$ref": "#/components/schemas/Coordinates" + }, + "itineraries": { + "items": { + "anyOf": [ + { "$ref": "#/components/schemas/Itinerary" }, + { + "$ref": "#/components/schemas/ItineraryWithInsight" + } + ] + }, + "type": "array", + "title": "Itineraries", + "description": "List of route itineraries" + }, + "search_time": { + "type": "string", + "format": "date-time", + "title": "Search Time", + "description": "Time when the search was performed" + } + }, + "type": "object", + "required": [ + "origin", + "destination", + "itineraries", + "search_time" + ], + "title": "RouteSearchResponse", + "description": "Response schema for route search endpoint." + }, + "ServiceHealth": { + "properties": { + "healthy": { "type": "boolean", "title": "Healthy" }, + "message": { "type": "string", "title": "Message" } + }, + "type": "object", + "required": ["healthy", "message"], + "title": "ServiceHealth" + }, + "Token": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token" + }, + "token_type": { "type": "string", "title": "Token Type" } + }, + "type": "object", + "required": ["access_token", "token_type"], + "title": "Token" + }, + "TransportMode": { + "type": "string", + "enum": [ + "WALK", + "BICYCLE", + "CAR", + "TRAM", + "SUBWAY", + "RAIL", + "BUS", + "FERRY" + ], + "title": "TransportMode", + "description": "Transport modes." + }, + "UserCreate": { + "properties": { + "username": { "type": "string", "title": "Username" }, + "password": { "type": "string", "title": "Password" } + }, + "type": "object", + "required": ["username", "password"], + "title": "UserCreate" + }, + "UserResponse": { + "properties": { + "username": { "type": "string", "title": "Username" }, + "id": { "type": "integer", "title": "Id" }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { "type": "string", "format": "date-time" }, + { "type": "null" } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": ["username", "id", "created_at"], + "title": "UserResponse" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { "type": "string", "title": "Message" }, + "type": { "type": "string", "title": "Error Type" } + }, + "type": "object", + "required": ["loc", "msg", "type"], + "title": "ValidationError" + } + }, + "securitySchemes": { + "OAuth2PasswordBearer": { + "type": "oauth2", + "flows": { + "password": { + "scopes": {}, + "tokenUrl": "/api/v1/auth/login" + } + } + } + } + } +} From 019c0927d70873b8dc96c3f9e29d0778b0489dc0 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Fri, 21 Nov 2025 12:52:26 +0200 Subject: [PATCH 05/10] refactor(prefs): refactor the preferences api --- src/components/RouteSpecificPreferences.tsx | 64 ++-- src/components/UserPreferences.tsx | 4 +- src/lib/api/preferences.ts | 376 +++++++++----------- 3 files changed, 214 insertions(+), 230 deletions(-) diff --git a/src/components/RouteSpecificPreferences.tsx b/src/components/RouteSpecificPreferences.tsx index adff23c..5b2e32b 100644 --- a/src/components/RouteSpecificPreferences.tsx +++ b/src/components/RouteSpecificPreferences.tsx @@ -3,24 +3,23 @@ import { useCallback, useEffect, useState } from "react"; import { MapPin, Plus, X } from "lucide-react-native"; import { TextInput, TouchableOpacity, View } from "react-native"; -import preferencesApi, { - Route, - RouteWithPreferences, -} from "@/lib/api/preferences"; +import preferencesApi from "@/lib/api/preferences"; import { useLocationService } from "@/lib/location"; import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { PlaceInput } from "./routing/PlaceInput"; +import { Coordinates } from "@/types/geo"; +import { RoutePreferences } from "@/types/preferences"; -const getRouteKey = (route: Route) => - `${route.fromLat},${route.fromLon},${route.toLat},${route.toLon}`; +const getRouteKey = (from: Coordinates, to: Coordinates) => + `${from.latitude},${from.longitude},${to.latitude},${to.longitude}`; export default function RouteSpecificPreferences() { const { getSuggestions, isValidPlace } = useLocationService(); const [routesWithPreferences, setRoutesWithPreferences] = useState< - RouteWithPreferences[] + RoutePreferences[] >([]); const [newPreferenceTexts, setNewPreferenceTexts] = useState< Record @@ -33,7 +32,7 @@ export default function RouteSpecificPreferences() { const fetchRoutesWithPreferences = useCallback(async () => { try { - const data = await preferencesApi.getRoutesWithPreferences(); + const data = await preferencesApi.getRoutePreferences(); setRoutesWithPreferences(data); } catch (error) { // In a real app, handle this error @@ -80,21 +79,24 @@ export default function RouteSpecificPreferences() { }; const handleAddRoutePreference = useCallback( - async (route: Route) => { - const routeKey = getRouteKey(route); + async (routePrefs: RoutePreferences) => { + const routeKey = getRouteKey( + routePrefs.from.coordinates, + routePrefs.to.coordinates + ); const prompt = newPreferenceTexts[routeKey]?.trim(); if (!prompt) return; - const newPref = await preferencesApi.addRouteSpecificPreference( - route, - { - prompt, - } + const newPref = await preferencesApi.addRoutePreference( + routePrefs.from.coordinates, + routePrefs.to.coordinates, + prompt ); setRoutesWithPreferences((prev) => prev.map((r) => - getRouteKey(r.route) === routeKey + getRouteKey(r.from.coordinates, r.to.coordinates) === + routeKey ? { ...r, preferences: [...r.preferences, newPref], @@ -108,15 +110,16 @@ export default function RouteSpecificPreferences() { ); const handleDeleteRoutePreference = useCallback( - async (route: Route, preferenceId: number) => { - await preferencesApi.deleteRouteSpecificPreference( - route, - preferenceId + async (routePrefs: RoutePreferences, preferenceId: number) => { + await preferencesApi.deleteRoutePreference(preferenceId); + const routeKey = getRouteKey( + routePrefs.from.coordinates, + routePrefs.to.coordinates ); - const routeKey = getRouteKey(route); setRoutesWithPreferences((prev) => prev.map((r) => - getRouteKey(r.route) === routeKey + getRouteKey(r.from.coordinates, r.to.coordinates) === + routeKey ? { ...r, preferences: r.preferences.filter( @@ -163,8 +166,11 @@ export default function RouteSpecificPreferences() { - {routesWithPreferences.map(({ route, preferences }) => { - const routeKey = getRouteKey(route); + {routesWithPreferences.map((routePrefs) => { + const routeKey = getRouteKey( + routePrefs.from.coordinates, + routePrefs.to.coordinates + ); return ( - {route.from} + {routePrefs.from.name} - {route.to} + {routePrefs.to.name} - {preferences.map((pref) => ( + {routePrefs.preferences.map((pref) => ( handleDeleteRoutePreference( - route, + routePrefs, pref.id ) } @@ -229,7 +235,7 @@ export default function RouteSpecificPreferences() { testID={`add-preference-button-${routeKey}`} size="sm" onPress={() => - handleAddRoutePreference(route) + handleAddRoutePreference(routePrefs) } disabled={ !newPreferenceTexts[routeKey]?.trim() diff --git a/src/components/UserPreferences.tsx b/src/components/UserPreferences.tsx index 8a9164a..d0f0711 100644 --- a/src/components/UserPreferences.tsx +++ b/src/components/UserPreferences.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { Plus, X } from "lucide-react-native"; import { TouchableOpacity, View } from "react-native"; -import preferencesApi, { type Preference } from "@/lib/api/preferences"; +import preferencesApi from "@/lib/api/preferences"; import { useAuth } from "@/hooks/useAuth"; @@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Text } from "@/components/ui/text"; +import { type Preference } from "@/types/preferences"; + const UserPreferences = () => { const { isLoaded } = useAuth(); const [preferences, setPreferences] = useState([]); diff --git a/src/lib/api/preferences.ts b/src/lib/api/preferences.ts index 355a7d2..ad2b3e9 100644 --- a/src/lib/api/preferences.ts +++ b/src/lib/api/preferences.ts @@ -1,158 +1,75 @@ import { locationService } from "../location"; -import z from "zod"; +import { z } from "zod"; import apiClient from "./client"; +import { Coordinates } from "@/types/geo"; +import { + Preference, + PreferenceSchema, + RoutePreferences, +} from "@/types/preferences"; -interface RouteData { - fromLat: number; - fromLon: number; - toLat: number; - toLon: number; -} - -export interface Route extends RouteData { - from: string | null; - to: string | null; -} - -const getRouteKey = (route: RouteData) => - `${route.fromLat},${route.fromLon},${route.toLat},${route.toLon}`; - -const exactum = locationService.geocodeSync("Exactum")!; -const kamppi = locationService.geocodeSync("Kamppi")!; -const pasila = locationService.geocodeSync("Pasila")!; -const helsinki = locationService.geocodeSync("Helsinki")!; -const espoo = locationService.geocodeSync("Espoo")!; - -// Mocked initial routes, in a real app this might come from a different source -const initialRoutesData: RouteData[] = [ - { - fromLat: exactum.lat, - fromLon: exactum.lon, - toLat: kamppi.lat, - toLon: kamppi.lon, - }, - { - fromLat: kamppi.lat, - fromLon: kamppi.lon, - toLat: pasila.lat, - toLon: pasila.lon, - }, -]; +// API-specific schemas (only used by this API module) +const NewPreferenceSchema = z.object({ + prompt: z.string(), +}); -let savedRoutesData: RouteData[] = [...initialRoutesData]; +const RoutePreferenceCreateSchema = z.object({ + prompt: z.string(), + from_latitude: z.number(), + from_longitude: z.number(), + to_latitude: z.number(), + to_longitude: z.number(), +}); -const PreferenceSchema = z.object({ +const RoutePreferenceResponseSchema = z.object({ id: z.number(), prompt: z.string(), created_at: z.string().datetime(), updated_at: z.string().datetime().nullable().optional(), + from_latitude: z.number(), + from_longitude: z.number(), + to_latitude: z.number(), + to_longitude: z.number(), }); -const PreferencesResponseSchema = z.array(PreferenceSchema); - -export type Preference = z.infer; +type RoutePreferenceResponse = z.infer; -export type PreferenceCreate = Omit< - Preference, - "id" | "created_at" | "updated_at" ->; +// Helper to create a unique key for route coordinates +const getRouteKey = (from: Coordinates, to: Coordinates) => + `${from.latitude},${from.longitude},${to.latitude},${to.longitude}`; -const RoutePreferenceSchema = z.object({ - id: z.number(), - prompt: z.string(), - from_latitude: z.number().min(-90).max(90), - from_longitude: z.number().min(-180).max(180), - to_latitude: z.number().min(-90).max(90), - to_longitude: z.number().min(-180).max(180), - created_at: z.string().datetime(), - updated_at: z.string().datetime().nullable().optional(), -}); +// Some initial routes for development +const exactum = locationService.geocodeSync("Exactum")!; +const kamppi = locationService.geocodeSync("Kamppi")!; +const pasila = locationService.geocodeSync("Pasila")!; -export type RoutePreference = z.infer; - -export interface RouteWithPreferences { - route: Route; - preferences: RoutePreference[]; -} - -const initialMockRoutePreferences: { [key: string]: RoutePreference[] } = { - [getRouteKey(initialRoutesData[0])]: [ - { - id: 1, - prompt: "Prefer bus 506", - from_latitude: helsinki.lat, - from_longitude: helsinki.lon, - to_latitude: espoo.lat, - to_longitude: espoo.lon, - created_at: new Date().toISOString(), - updated_at: null, - }, - { - id: 2, - prompt: "Never use the tram for this route", - from_latitude: helsinki.lat, - from_longitude: helsinki.lon, - to_latitude: espoo.lat, - to_longitude: espoo.lon, - created_at: new Date().toISOString(), - updated_at: null, - }, - { - id: 3, - prompt: "Avoid rush hour metro", - from_latitude: helsinki.lat, - from_longitude: helsinki.lon, - to_latitude: espoo.lat, - to_longitude: espoo.lon, - created_at: new Date().toISOString(), - updated_at: null, - }, - ], - [getRouteKey(initialRoutesData[1])]: [ - { - id: 4, - prompt: "Always use metro when available", - from_latitude: helsinki.lat, - from_longitude: helsinki.lon, - to_latitude: pasila.lat, - to_longitude: pasila.lon, - created_at: new Date().toISOString(), - updated_at: null, - }, - { - id: 5, - prompt: "Avoid walking through Töölö", - from_latitude: helsinki.lat, - from_longitude: helsinki.lon, - to_latitude: pasila.lat, - to_longitude: pasila.lon, - created_at: new Date().toISOString(), - updated_at: null, - }, - ], -}; +const initialRoutes: Coordinates[] = [ + { latitude: exactum.lat, longitude: exactum.lon }, + { latitude: kamppi.lat, longitude: kamppi.lon }, + { latitude: pasila.lat, longitude: pasila.lon }, +]; -// Use a deep copy for the mutable state -let mockRoutePreferences: Record = JSON.parse( - JSON.stringify(initialMockRoutePreferences) -); -let nextId = 100; +let savedRoutes: { from: Coordinates; to: Coordinates }[] = [ + { from: initialRoutes[0], to: initialRoutes[1] }, + { from: initialRoutes[1], to: initialRoutes[2] }, +]; const preferencesApi = { async getPreferences(): Promise { return apiClient.get( "/users/preferences", {}, - PreferencesResponseSchema + z.array(PreferenceSchema) ); }, async addPreference(prompt: string): Promise { + const newPreference = NewPreferenceSchema.parse({ prompt }); return apiClient.post( "/users/preferences", - { prompt }, + newPreference, {}, PreferenceSchema ); @@ -162,74 +79,138 @@ const preferencesApi = { return apiClient.delete(`/users/preferences/${preferenceId}`); }, - async getRoutesWithPreferences(): Promise { - const resolvedRoutes: Route[] = await Promise.all( - savedRoutesData.map(async (routeData) => { - const fromName = await locationService.reverseGeocodeAsync( - routeData.fromLat, - routeData.fromLon - ); - const toName = await locationService.reverseGeocodeAsync( - routeData.toLat, - routeData.toLon - ); - return { - ...routeData, - from: fromName, - to: toName, - }; - }) + // Get all route preferences and group them by route + async getRoutePreferences(): Promise { + const routePreferenceResponses = await apiClient.get< + RoutePreferenceResponse[] + >( + "/users/route-preferences", + {}, + z.array(RoutePreferenceResponseSchema) ); - const routesWithPreferences = resolvedRoutes.map((route) => ({ - route, - preferences: mockRoutePreferences[getRouteKey(route)] || [], + // Convert API responses to regular preferences + const preferences: (Preference & { + from: Coordinates; + to: Coordinates; + })[] = routePreferenceResponses.map((resp) => ({ + id: resp.id, + prompt: resp.prompt, + created_at: resp.created_at, + updated_at: resp.updated_at, + from: { + latitude: resp.from_latitude, + longitude: resp.from_longitude, + }, + to: { latitude: resp.to_latitude, longitude: resp.to_longitude }, })); - return Promise.resolve(routesWithPreferences); + // Group by route coordinates + const routeMap = new Map(); + + for (const pref of preferences) { + const routeKey = getRouteKey(pref.from, pref.to); + + if (!routeMap.has(routeKey)) { + routeMap.set(routeKey, { + from: { + coordinates: pref.from, + }, + to: { + coordinates: pref.to, + }, + preferences: [], + }); + } + + routeMap.get(routeKey)!.preferences.push({ + id: pref.id, + prompt: pref.prompt, + created_at: pref.created_at, + updated_at: pref.updated_at, + }); + } + + // Add saved routes that don't have preferences yet + for (const savedRoute of savedRoutes) { + const routeKey = getRouteKey(savedRoute.from, savedRoute.to); + if (!routeMap.has(routeKey)) { + routeMap.set(routeKey, { + from: { + coordinates: savedRoute.from, + }, + to: { + coordinates: savedRoute.to, + }, + preferences: [], + }); + } + } + + // Add location names and return + const result: RoutePreferences[] = []; + for (const route of routeMap.values()) { + const fromName = await locationService.reverseGeocodeAsync( + route.from.coordinates.latitude, + route.from.coordinates.longitude + ); + const toName = await locationService.reverseGeocodeAsync( + route.to.coordinates.latitude, + route.to.coordinates.longitude + ); + + result.push({ + from: { + coordinates: route.from.coordinates, + name: fromName, + }, + to: { + coordinates: route.to.coordinates, + name: toName, + }, + preferences: route.preferences, + }); + } + + return result; }, - async addRouteSpecificPreference( - route: RouteData, - preference: PreferenceCreate - ): Promise { - const routeKey = getRouteKey(route); - console.log( - `Adding preference to route ${routeKey}: ${preference.prompt}` + async addRoutePreference( + from: Coordinates, + to: Coordinates, + prompt: string + ): Promise { + const createData = RoutePreferenceCreateSchema.parse({ + prompt, + from_latitude: from.latitude, + from_longitude: from.longitude, + to_latitude: to.latitude, + to_longitude: to.longitude, + }); + + const response = await apiClient.post( + "/users/route-preferences", + createData, + {}, + RoutePreferenceResponseSchema ); - const newPreference: RoutePreference = { - id: nextId++, // Incrementing ID to ensure uniqueness - ...preference, - prompt: preference.prompt, - from_latitude: espoo.lat, - from_longitude: espoo.lon, - to_latitude: helsinki.lat, - to_longitude: helsinki.lon, - created_at: new Date().toISOString(), - updated_at: null, + + // Return as regular preference (the route info is just for grouping) + return { + id: response.id, + prompt: response.prompt, + created_at: response.created_at, + updated_at: response.updated_at, }; - const existingPrefs = mockRoutePreferences[routeKey] || []; - mockRoutePreferences[routeKey] = [...existingPrefs, newPreference]; - return Promise.resolve(newPreference); }, - async deleteRouteSpecificPreference( - route: RouteData, - preferenceId: number - ): Promise { - const routeKey = getRouteKey(route); - console.log( - `Deleting preference ${preferenceId} from route ${routeKey}` + async deleteRoutePreference(preferenceId: number): Promise { + await apiClient.delete( + `/users/route-preferences/${preferenceId}` ); - if (mockRoutePreferences[routeKey]) { - mockRoutePreferences[routeKey] = mockRoutePreferences[ - routeKey - ].filter((p) => p.id !== preferenceId); - } - return Promise.resolve(); }, - addSavedRoute: async (from: string, to: string): Promise => { + async addSavedRoute(from: string, to: string): Promise { const fromCoords = await locationService.geocode(from); const toCoords = await locationService.geocode(to); @@ -238,33 +219,28 @@ const preferencesApi = { return; } - const newRouteData: RouteData = { - fromLat: fromCoords.lat, - fromLon: fromCoords.lon, - toLat: toCoords.lat, - toLon: toCoords.lon, + const newRoute = { + from: { latitude: fromCoords.lat, longitude: fromCoords.lon }, + to: { latitude: toCoords.lat, longitude: toCoords.lon }, }; - const routeKey = getRouteKey(newRouteData); - const routeExists = savedRoutesData.some( - (route) => getRouteKey(route) === routeKey + // Check if route already exists + const routeKey = getRouteKey(newRoute.from, newRoute.to); + const exists = savedRoutes.some( + (route) => getRouteKey(route.from, route.to) === routeKey ); - if (routeExists) { - return; + if (!exists) { + savedRoutes.push(newRoute); } - - savedRoutesData.push(newRouteData); - mockRoutePreferences[routeKey] = []; }, - // Test helper to reset the mock state + // Test helper __resetMocks: () => { - mockRoutePreferences = JSON.parse( - JSON.stringify(initialMockRoutePreferences) - ); - savedRoutesData = [...initialRoutesData]; - nextId = 100; + savedRoutes = [ + { from: initialRoutes[0], to: initialRoutes[1] }, + { from: initialRoutes[1], to: initialRoutes[2] }, + ]; }, }; From 47c6e013b06a7417cdb1751ce050b33fbf49aae1 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Fri, 21 Nov 2025 12:53:15 +0200 Subject: [PATCH 06/10] tests(prefs): fix the preference tests --- .../components/RouteSpecificPreferences.tsx | 290 ++++++++---------- src/__tests__/components/UserPreferences.tsx | 3 +- src/__tests__/lib/api/preferences.ts | 3 +- 3 files changed, 133 insertions(+), 163 deletions(-) diff --git a/src/__tests__/components/RouteSpecificPreferences.tsx b/src/__tests__/components/RouteSpecificPreferences.tsx index 8e09224..08197be 100644 --- a/src/__tests__/components/RouteSpecificPreferences.tsx +++ b/src/__tests__/components/RouteSpecificPreferences.tsx @@ -1,277 +1,245 @@ import React from "react"; -import { - fireEvent, - render, - waitFor, - within, -} from "@testing-library/react-native"; - -import preferencesApi, { - Route, - RoutePreference, - RouteWithPreferences, -} from "@/lib/api/preferences"; +import { fireEvent, render, waitFor } from "@testing-library/react-native"; + +import preferencesApi from "@/lib/api/preferences"; import RouteSpecificPreferences from "@/components/RouteSpecificPreferences"; -const getRouteKey = (route: Route) => - `${route.fromLat},${route.fromLon},${route.toLat},${route.toLon}`; +import { Coordinates } from "@/types/geo"; +import { Preference, RoutePreferences } from "@/types/preferences"; + +const getRouteKey = (from: Coordinates, to: Coordinates) => + `${from.latitude},${from.longitude},${to.latitude},${to.longitude}`; // Mock data -const MOCK_ROUTES_WITH_PREFERENCES: RouteWithPreferences[] = [ +const MOCK_ROUTES_WITH_PREFERENCES: RoutePreferences[] = [ { - route: { - from: "Exactum", - to: "Kamppi", - fromLat: 60.204, - fromLon: 24.962, - toLat: 60.169, - toLon: 24.932, + from: { + coordinates: { latitude: 60.204, longitude: 24.962 }, + name: "Exactum", + }, + to: { + coordinates: { latitude: 60.169, longitude: 24.932 }, + name: "Kamppi", }, preferences: [ { id: 1, prompt: "Prefer bus 506", created_at: "2023-01-01T12:00:00.000Z", - from_latitude: 60.204, - from_longitude: 24.962, - to_latitude: 60.169, - to_longitude: 24.932, updated_at: null, }, { id: 2, prompt: "Never use the tram for this route", created_at: "2023-01-01T12:00:00.000Z", - from_latitude: 60.204, - from_longitude: 24.962, - to_latitude: 60.169, - to_longitude: 24.932, updated_at: null, }, { id: 3, prompt: "Avoid rush hour metro", created_at: "2023-01-01T12:00:00.000Z", - from_latitude: 60.204, - from_longitude: 24.962, - to_latitude: 60.169, - to_longitude: 24.932, updated_at: null, }, ], }, { - route: { - from: "Kamppi", - to: "Pasila", - fromLat: 60.169, - fromLon: 24.932, - toLat: 60.199, - toLon: 24.934, + from: { + coordinates: { latitude: 60.169, longitude: 24.932 }, + name: "Kamppi", + }, + to: { + coordinates: { latitude: 60.199, longitude: 24.934 }, + name: "Pasila", }, preferences: [ { id: 4, prompt: "Always use metro when available", created_at: "2023-01-01T12:00:00.000Z", - from_latitude: 60.169, - from_longitude: 24.932, - to_latitude: 60.199, - to_longitude: 24.934, updated_at: null, }, { id: 5, prompt: "Avoid walking through Töölö", created_at: "2023-01-01T12:00:00.000Z", - from_latitude: 60.169, - from_longitude: 24.932, - to_latitude: 60.199, - to_longitude: 24.934, updated_at: null, }, ], }, ]; -let mockRoutesWithPreferences: RouteWithPreferences[]; +let mockRoutesWithPreferences: RoutePreferences[]; let mockNextPreferenceId: number; // Mock the API module jest.mock("@/lib/api/preferences", () => ({ - getRoutesWithPreferences: jest.fn(async () => mockRoutesWithPreferences), - addRouteSpecificPreference: jest.fn( - async (route: Route, { prompt }: { prompt: string }) => { - const newPreference: RoutePreference = { + getRoutePreferences: jest.fn(async () => mockRoutesWithPreferences), + addRoutePreference: jest.fn( + async (from: Coordinates, to: Coordinates, prompt: string) => { + const newPreference: Preference = { id: mockNextPreferenceId++, prompt, created_at: new Date().toISOString(), - from_latitude: route.fromLat, - from_longitude: route.fromLon, - to_latitude: route.toLat, - to_longitude: route.toLon, updated_at: null, }; mockRoutesWithPreferences = mockRoutesWithPreferences.map((r) => - getRouteKey(r.route) === getRouteKey(route) + getRouteKey(r.from.coordinates, r.to.coordinates) === + getRouteKey(from, to) ? { ...r, preferences: [...r.preferences, newPreference] } : r ); return newPreference; } ), - deleteRouteSpecificPreference: jest.fn( - async (route: Route, preferenceId: number) => { - mockRoutesWithPreferences = mockRoutesWithPreferences.map((r) => - getRouteKey(r.route) === getRouteKey(route) - ? { - ...r, - preferences: r.preferences.filter( - (p) => p.id !== preferenceId - ), - } - : r - ); - } - ), + deleteRoutePreference: jest.fn(async (preferenceId: number) => { + mockRoutesWithPreferences = mockRoutesWithPreferences.map((r) => ({ + ...r, + preferences: r.preferences.filter((p) => p.id !== preferenceId), + })); + }), addSavedRoute: jest.fn(async (from: string, to: string) => { - const newRoute: RouteWithPreferences = { - route: { - from, - to, - fromLat: 60.224, // Dummy coords - fromLon: 24.952, - toLat: 60.189, - toLon: 25.042, + const newRoute: RoutePreferences = { + from: { + coordinates: { latitude: 0, longitude: 0 }, + name: from, + }, + to: { + coordinates: { latitude: 0, longitude: 0 }, + name: to, }, preferences: [], }; mockRoutesWithPreferences.push(newRoute); }), - __resetMocks: () => { - mockRoutesWithPreferences = JSON.parse( - JSON.stringify(MOCK_ROUTES_WITH_PREFERENCES) - ); - mockNextPreferenceId = 6; - }, })); -// Mock the location service hook +// Mock the location service jest.mock("@/lib/location", () => ({ useLocationService: () => ({ - getSuggestions: jest.fn().mockResolvedValue([]), - isValidPlace: jest.fn().mockReturnValue(true), + getSuggestions: jest.fn(() => + Promise.resolve([ + { name: "Helsinki", lat: 60.1699, lon: 24.9384 }, + { name: "Espoo", lat: 60.2055, lon: 24.6559 }, + ]) + ), + isValidPlace: jest.fn((place: string) => + ["Helsinki", "Espoo", "Vantaa", "Tampere"].includes(place) + ), }), })); -describe("RouteSpecificPreferences component", () => { - // Define route keys based on mock data - const route1Key = getRouteKey(MOCK_ROUTES_WITH_PREFERENCES[0].route); - const route2Key = getRouteKey(MOCK_ROUTES_WITH_PREFERENCES[1].route); - +describe("RouteSpecificPreferences", () => { beforeEach(() => { - preferencesApi.__resetMocks(); + mockRoutesWithPreferences = [...MOCK_ROUTES_WITH_PREFERENCES]; + mockNextPreferenceId = 100; + jest.clearAllMocks(); }); - it("should render initial routes and preferences", async () => { - const { findByTestId } = render(); + it("renders route preferences correctly", async () => { + const screen = render(); - // Check for first route - const route1 = await findByTestId(`route-preferences-${route1Key}`); - expect(within(route1).getByText("Exactum")).toBeTruthy(); - expect(within(route1).getByText("Kamppi")).toBeTruthy(); - expect( - within(route1).getByText("Never use the tram for this route") - ).toBeTruthy(); - expect(within(route1).getByText("Prefer bus 506")).toBeTruthy(); - expect(within(route1).getByText("Avoid rush hour metro")).toBeTruthy(); + await waitFor(() => { + expect(screen.getByText("Exactum")).toBeTruthy(); + expect(screen.getAllByText("Kamppi")).toHaveLength(2); // appears as from and to + expect(screen.getByText("Pasila")).toBeTruthy(); + }); - // Check for second route - const route2 = await findByTestId(`route-preferences-${route2Key}`); - expect(within(route2).getByText("Kamppi")).toBeTruthy(); - expect(within(route2).getByText("Pasila")).toBeTruthy(); + // Check preferences are displayed + expect(screen.getByText("Prefer bus 506")).toBeTruthy(); expect( - within(route2).getByText("Always use metro when available") + screen.getByText("Never use the tram for this route") ).toBeTruthy(); expect( - within(route2).getByText("Avoid walking through Töölö") + screen.getByText("Always use metro when available") ).toBeTruthy(); }); - it("should add a new preference to a route", async () => { - const { getByTestId, findByText, findByTestId } = render( - + it("can add a new preference to a route", async () => { + const screen = render(); + + await waitFor(() => { + expect(screen.getByText("Exactum")).toBeTruthy(); + }); + + const routeKey = getRouteKey( + MOCK_ROUTES_WITH_PREFERENCES[0].from.coordinates, + MOCK_ROUTES_WITH_PREFERENCES[0].to.coordinates ); - const newPreference = "Use a city bike"; - const input = await findByTestId(`add-preference-input-${route1Key}`); - fireEvent.changeText(input, newPreference); + // Find input for the first route + const input = screen.getByTestId(`add-preference-input-${routeKey}`); + const button = screen.getByTestId(`add-preference-button-${routeKey}`); - const addButton = getByTestId(`add-preference-button-${route1Key}`); - fireEvent.press(addButton); + // Add new preference + fireEvent.changeText(input, "New preference"); + fireEvent.press(button); - await waitFor(async () => { - expect(await findByText(newPreference)).toBeTruthy(); + await waitFor(() => { + expect(preferencesApi.addRoutePreference).toHaveBeenCalledWith( + MOCK_ROUTES_WITH_PREFERENCES[0].from.coordinates, + MOCK_ROUTES_WITH_PREFERENCES[0].to.coordinates, + "New preference" + ); }); }); - it("should delete a preference from a route", async () => { - const { queryByText, findByTestId } = render( - + it("can delete a preference from a route", async () => { + const screen = render(); + + await waitFor(() => { + expect(screen.getByText("Prefer bus 506")).toBeTruthy(); + }); + + const routeKey = getRouteKey( + MOCK_ROUTES_WITH_PREFERENCES[0].from.coordinates, + MOCK_ROUTES_WITH_PREFERENCES[0].to.coordinates ); - const preferenceToDelete = "Prefer bus 506"; - const preferenceId = MOCK_ROUTES_WITH_PREFERENCES[0].preferences[0].id; - const deleteButton = await findByTestId( - `delete-preference-${route1Key}-${preferenceId}` + // Find delete button for first preference + const deleteButton = screen.getByTestId( + `delete-preference-${routeKey}-1` ); + fireEvent.press(deleteButton); await waitFor(() => { - expect(queryByText(preferenceToDelete)).toBeNull(); + expect(preferencesApi.deleteRoutePreference).toHaveBeenCalledWith( + 1 + ); }); }); - it("should add a new route", async () => { - const { getByTestId, findByText, findByPlaceholderText } = render( - - ); + it("can add a new route", async () => { + const screen = render(); - const addNewRouteButton = getByTestId("add-new-route-button"); - fireEvent.press(addNewRouteButton); - - const fromInput = await findByPlaceholderText("From"); - const toInput = await findByPlaceholderText("To"); - const addRouteButton = getByTestId("add-route-button"); - - fireEvent.changeText(fromInput, "Kumpula"); - fireEvent.changeText(toInput, "Herttoniemi"); + await waitFor(() => { + expect(screen.getByText("Add new route preference")).toBeTruthy(); + }); - fireEvent.press(addRouteButton); + // Open new route form + fireEvent.press(screen.getByTestId("add-new-route-button")); - await waitFor(async () => { - // The component refetches after adding, so we wait for the new content to appear - expect(await findByText("Kumpula")).toBeTruthy(); - expect(await findByText("Herttoniemi")).toBeTruthy(); + await waitFor(() => { + expect(screen.getByTestId("new-route-from-input")).toBeTruthy(); + expect(screen.getByTestId("new-route-to-input")).toBeTruthy(); }); - }); - - it("should cancel adding a new route", async () => { - const { getByTestId, queryByPlaceholderText, findByTestId } = render( - - ); - const addNewRouteButton = getByTestId("add-new-route-button"); - fireEvent.press(addNewRouteButton); + // Fill in route details + const fromInput = screen.getByTestId("new-route-from-input"); + const toInput = screen.getByTestId("new-route-to-input"); + const addButton = screen.getByTestId("add-route-button"); - const cancelButton = await findByTestId("cancel-add-route-button"); - fireEvent.press(cancelButton); + fireEvent.changeText(fromInput, "Helsinki"); + fireEvent.changeText(toInput, "Espoo"); + fireEvent.press(addButton); await waitFor(() => { - expect(queryByPlaceholderText("From")).toBeNull(); - expect(queryByPlaceholderText("To")).toBeNull(); + expect(preferencesApi.addSavedRoute).toHaveBeenCalledWith( + "Helsinki", + "Espoo" + ); }); }); }); diff --git a/src/__tests__/components/UserPreferences.tsx b/src/__tests__/components/UserPreferences.tsx index 9712ab6..9b43067 100644 --- a/src/__tests__/components/UserPreferences.tsx +++ b/src/__tests__/components/UserPreferences.tsx @@ -3,12 +3,13 @@ import React from "react"; import { fireEvent, render, waitFor } from "@testing-library/react-native"; import preferencesApi from "@/lib/api/preferences"; -import { type Preference } from "@/lib/api/preferences"; import { useAuth } from "@/hooks/useAuth"; import UserPreferences from "@/components/UserPreferences"; +import { type Preference } from "@/types/preferences"; + jest.mock("@/hooks/useAuth"); jest.mock("@/lib/api/preferences"); diff --git a/src/__tests__/lib/api/preferences.ts b/src/__tests__/lib/api/preferences.ts index 4b8da19..fca7aa0 100644 --- a/src/__tests__/lib/api/preferences.ts +++ b/src/__tests__/lib/api/preferences.ts @@ -1,7 +1,8 @@ import apiClient from "@/lib/api/client"; -import preferencesApi, { Preference } from "@/lib/api/preferences"; +import preferencesApi from "@/lib/api/preferences"; import { ApiError } from "@/types/api"; +import { Preference } from "@/types/preferences"; jest.mock("@/lib/api/client"); From 3d839014c9370b034bb7111fb20010256bb2f3b6 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Fri, 21 Nov 2025 13:04:30 +0200 Subject: [PATCH 07/10] refactor(prefs): remove initial hardcoded routepreferences --- src/components/RouteSpecificPreferences.tsx | 27 ++++++++- src/lib/api/preferences.ts | 65 --------------------- 2 files changed, 24 insertions(+), 68 deletions(-) diff --git a/src/components/RouteSpecificPreferences.tsx b/src/components/RouteSpecificPreferences.tsx index 5b2e32b..d07f66d 100644 --- a/src/components/RouteSpecificPreferences.tsx +++ b/src/components/RouteSpecificPreferences.tsx @@ -144,11 +144,32 @@ export default function RouteSpecificPreferences() { return; } - await preferencesApi.addSavedRoute(fromName, toName); + // Get coordinates for the places + const fromSuggestions = await getSuggestions(fromName); + const toSuggestions = await getSuggestions(toName); - // Refetch the routes to get the updated list - await fetchRoutesWithPreferences(); + const fromPlace = fromSuggestions.find((p) => p.name === fromName); + const toPlace = toSuggestions.find((p) => p.name === toName); + if (!fromPlace || !toPlace) { + console.warn("Could not find coordinates for the places"); + return; + } + + // Add new empty route to state + const newRoute: RoutePreferences = { + from: { + coordinates: fromPlace.coordinates, + name: fromName, + }, + to: { + coordinates: toPlace.coordinates, + name: toName, + }, + preferences: [], + }; + + setRoutesWithPreferences((prev) => [...prev, newRoute]); setNewRouteFrom(""); setNewRouteTo(""); setFromSuggestions([]); diff --git a/src/lib/api/preferences.ts b/src/lib/api/preferences.ts index ad2b3e9..2061e1c 100644 --- a/src/lib/api/preferences.ts +++ b/src/lib/api/preferences.ts @@ -40,22 +40,6 @@ type RoutePreferenceResponse = z.infer; const getRouteKey = (from: Coordinates, to: Coordinates) => `${from.latitude},${from.longitude},${to.latitude},${to.longitude}`; -// Some initial routes for development -const exactum = locationService.geocodeSync("Exactum")!; -const kamppi = locationService.geocodeSync("Kamppi")!; -const pasila = locationService.geocodeSync("Pasila")!; - -const initialRoutes: Coordinates[] = [ - { latitude: exactum.lat, longitude: exactum.lon }, - { latitude: kamppi.lat, longitude: kamppi.lon }, - { latitude: pasila.lat, longitude: pasila.lon }, -]; - -let savedRoutes: { from: Coordinates; to: Coordinates }[] = [ - { from: initialRoutes[0], to: initialRoutes[1] }, - { from: initialRoutes[1], to: initialRoutes[2] }, -]; - const preferencesApi = { async getPreferences(): Promise { return apiClient.get( @@ -131,22 +115,6 @@ const preferencesApi = { }); } - // Add saved routes that don't have preferences yet - for (const savedRoute of savedRoutes) { - const routeKey = getRouteKey(savedRoute.from, savedRoute.to); - if (!routeMap.has(routeKey)) { - routeMap.set(routeKey, { - from: { - coordinates: savedRoute.from, - }, - to: { - coordinates: savedRoute.to, - }, - preferences: [], - }); - } - } - // Add location names and return const result: RoutePreferences[] = []; for (const route of routeMap.values()) { @@ -209,39 +177,6 @@ const preferencesApi = { `/users/route-preferences/${preferenceId}` ); }, - - async addSavedRoute(from: string, to: string): Promise { - const fromCoords = await locationService.geocode(from); - const toCoords = await locationService.geocode(to); - - if (!fromCoords || !toCoords) { - console.warn("Could not find coordinates for one of the locations"); - return; - } - - const newRoute = { - from: { latitude: fromCoords.lat, longitude: fromCoords.lon }, - to: { latitude: toCoords.lat, longitude: toCoords.lon }, - }; - - // Check if route already exists - const routeKey = getRouteKey(newRoute.from, newRoute.to); - const exists = savedRoutes.some( - (route) => getRouteKey(route.from, route.to) === routeKey - ); - - if (!exists) { - savedRoutes.push(newRoute); - } - }, - - // Test helper - __resetMocks: () => { - savedRoutes = [ - { from: initialRoutes[0], to: initialRoutes[1] }, - { from: initialRoutes[1], to: initialRoutes[2] }, - ]; - }, }; export default preferencesApi; From b69afdb42e7086aa86e2a9e8a7d0732f8b7fc495 Mon Sep 17 00:00:00 2001 From: 3nd3r1 Date: Fri, 21 Nov 2025 13:04:48 +0200 Subject: [PATCH 08/10] tests(prefs): fix prefs tests after removing hardcoded stuff --- .../components/RouteSpecificPreferences.tsx | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/__tests__/components/RouteSpecificPreferences.tsx b/src/__tests__/components/RouteSpecificPreferences.tsx index 08197be..44fc6b8 100644 --- a/src/__tests__/components/RouteSpecificPreferences.tsx +++ b/src/__tests__/components/RouteSpecificPreferences.tsx @@ -99,20 +99,6 @@ jest.mock("@/lib/api/preferences", () => ({ preferences: r.preferences.filter((p) => p.id !== preferenceId), })); }), - addSavedRoute: jest.fn(async (from: string, to: string) => { - const newRoute: RoutePreferences = { - from: { - coordinates: { latitude: 0, longitude: 0 }, - name: from, - }, - to: { - coordinates: { latitude: 0, longitude: 0 }, - name: to, - }, - preferences: [], - }; - mockRoutesWithPreferences.push(newRoute); - }), })); // Mock the location service @@ -120,8 +106,14 @@ jest.mock("@/lib/location", () => ({ useLocationService: () => ({ getSuggestions: jest.fn(() => Promise.resolve([ - { name: "Helsinki", lat: 60.1699, lon: 24.9384 }, - { name: "Espoo", lat: 60.2055, lon: 24.6559 }, + { + name: "Helsinki", + coordinates: { latitude: 60.1699, longitude: 24.9384 }, + }, + { + name: "Espoo", + coordinates: { latitude: 60.2055, longitude: 24.6559 }, + }, ]) ), isValidPlace: jest.fn((place: string) => @@ -236,10 +228,9 @@ describe("RouteSpecificPreferences", () => { fireEvent.press(addButton); await waitFor(() => { - expect(preferencesApi.addSavedRoute).toHaveBeenCalledWith( - "Helsinki", - "Espoo" - ); + // Check that the new route appears in the UI + expect(screen.getByText("Helsinki")).toBeTruthy(); + expect(screen.getByText("Espoo")).toBeTruthy(); }); }); }); From df66fb55a4b1b1e71548af1985e17e61b7a3aa4a Mon Sep 17 00:00:00 2001 From: Eljas Veijalainen <114503308+EljasV@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:43:07 +0200 Subject: [PATCH 09/10] feat: add more hardcoded locations in Helsinki --- src/lib/location.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/lib/location.ts b/src/lib/location.ts index 2057623..d3e515a 100644 --- a/src/lib/location.ts +++ b/src/lib/location.ts @@ -72,6 +72,116 @@ const HELSINKI_PLACES: Place[] = [ }, { coordinates: { latitude: 60.199, longitude: 24.934 }, name: "Pasila" }, { coordinates: { latitude: 60.204, longitude: 24.962 }, name: "Exactum" }, + { + coordinates: { latitude: 60.145, longitude: 24.985 }, + name: "Suomenlinna", + }, + { + coordinates: { latitude: 60.188, longitude: 24.942 }, + name: "Linnanmäki", + }, + { coordinates: { latitude: 60.177, longitude: 24.915 }, name: "Töölö" }, + { coordinates: { latitude: 60.16, longitude: 24.938 }, name: "Punavuori" }, + { coordinates: { latitude: 60.191, longitude: 24.945 }, name: "Alppila" }, + { coordinates: { latitude: 60.203, longitude: 24.965 }, name: "Kumpula" }, + { coordinates: { latitude: 60.173, longitude: 24.938 }, name: "Oodi" }, + { + coordinates: { latitude: 60.172, longitude: 24.925 }, + name: "Temppeliaukion kirkko", + }, + { coordinates: { latitude: 60.167, longitude: 24.945 }, name: "Esplanadi" }, + { coordinates: { latitude: 60.179, longitude: 24.952 }, name: "Hakaniemi" }, + { + coordinates: { latitude: 60.184, longitude: 24.887 }, + name: "Seurasaari", + }, + { + coordinates: { latitude: 60.158, longitude: 24.956 }, + name: "Kaivopuisto", + }, + { + coordinates: { latitude: 60.168, longitude: 24.96 }, + name: "Uspenskin katedraali", + }, + { coordinates: { latitude: 60.17, longitude: 24.945 }, name: "Ateneum" }, + { coordinates: { latitude: 60.172, longitude: 24.935 }, name: "Kiasma" }, + { coordinates: { latitude: 60.16, longitude: 24.87 }, name: "Lauttasaari" }, + { coordinates: { latitude: 60.196, longitude: 24.96 }, name: "Vallila" }, + { + coordinates: { latitude: 60.21, longitude: 24.978 }, + name: "Arabianranta", + }, + { + coordinates: { latitude: 60.198, longitude: 24.869 }, + name: "Munkkiniemi", + }, + { coordinates: { latitude: 60.223, longitude: 24.895 }, name: "Haaga" }, + { coordinates: { latitude: 60.224, longitude: 25.077 }, name: "Itäkeskus" }, + { coordinates: { latitude: 60.239, longitude: 25.085 }, name: "Kontula" }, + { coordinates: { latitude: 60.252, longitude: 25.021 }, name: "Malmi" }, + { + coordinates: { latitude: 60.174, longitude: 24.931 }, + name: "Finlandia-talo", + }, + { + coordinates: { latitude: 60.173, longitude: 24.933 }, + name: "Musiikkitalo", + }, + { + coordinates: { latitude: 60.175, longitude: 24.932 }, + name: "Kansallismuseo", + }, + { + coordinates: { latitude: 60.187, longitude: 24.927 }, + name: "Olympiastadion", + }, + { + coordinates: { latitude: 60.163, longitude: 24.915 }, + name: "Ruoholahti", + }, + { + coordinates: { latitude: 60.155, longitude: 24.917 }, + name: "Jätkäsaari", + }, + { + coordinates: { latitude: 60.187, longitude: 24.978 }, + name: "Kalasatama", + }, + { + coordinates: { latitude: 60.195, longitude: 25.045 }, + name: "Herttoniemi", + }, + { coordinates: { latitude: 60.185, longitude: 25.021 }, name: "Kulosaari" }, + { coordinates: { latitude: 60.191, longitude: 24.894 }, name: "Meilahti" }, + { coordinates: { latitude: 60.178, longitude: 25.06 }, name: "Laajasalo" }, + { + coordinates: { latitude: 60.182, longitude: 24.913 }, + name: "Sibelius Monument", + }, + { + coordinates: { latitude: 60.17, longitude: 24.952 }, + name: "Helsinki Cathedral", + }, + { coordinates: { latitude: 60.165, longitude: 24.94 }, name: "Bulevardi" }, + { coordinates: { latitude: 60.18, longitude: 24.957 }, name: "Caisa" }, + { + coordinates: { latitude: 60.162, longitude: 24.941 }, + name: "Dianapuisto", + }, + { + coordinates: { latitude: 60.198, longitude: 24.845 }, + name: "Gallen-Kallelan Museo", + }, + { coordinates: { latitude: 60.295, longitude: 24.568 }, name: "Nuuksio" }, + { + coordinates: { latitude: 60.178, longitude: 25.045 }, + name: "Villa Wuorio", + }, + { + coordinates: { latitude: 60.167, longitude: 24.938 }, + name: "Yrjönkadun uimahalli", + }, + { coordinates: { latitude: 60.26, longitude: 25.21 }, name: "Östersundom" }, ]; class HardcodedLocationService implements LocationService { From 585999374681af1a4c38c5b8d208e504af0ce442 Mon Sep 17 00:00:00 2001 From: Eljas Veijalainen <114503308+EljasV@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:52:20 +0200 Subject: [PATCH 10/10] test: update location test expectations --- src/__tests__/lib/location.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/__tests__/lib/location.ts b/src/__tests__/lib/location.ts index b335258..7b93bc4 100644 --- a/src/__tests__/lib/location.ts +++ b/src/__tests__/lib/location.ts @@ -4,10 +4,11 @@ describe("Location Service", () => { describe("getSuggestions", () => { it("returns suggestions for valid partial input", async () => { const suggestions = await locationService.getSuggestions("Hel"); - expect(suggestions).toHaveLength(2); + expect(suggestions).toHaveLength(3); expect(suggestions.map((s) => s.name)).toEqual([ "Helsinki", "Helsingin Yliopisto", + "Helsinki Cathedral", ]); }); @@ -23,10 +24,11 @@ describe("Location Service", () => { it("returns case-insensitive matches", async () => { const suggestions = await locationService.getSuggestions("hel"); - expect(suggestions).toHaveLength(2); + expect(suggestions).toHaveLength(3); expect(suggestions.map((s) => s.name)).toEqual([ "Helsinki", "Helsingin Yliopisto", + "Helsinki Cathedral", ]); }); });