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/assets/openapi.json b/assets/openapi.json new file mode 100644 index 0000000..dfbc099 --- /dev/null +++ b/assets/openapi.json @@ -0,0 +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" + } + } + } + } + } +} 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": { diff --git a/src/__tests__/components/RouteSpecificPreferences.tsx b/src/__tests__/components/RouteSpecificPreferences.tsx index 8e09224..44fc6b8 100644 --- a/src/__tests__/components/RouteSpecificPreferences.tsx +++ b/src/__tests__/components/RouteSpecificPreferences.tsx @@ -1,277 +1,236 @@ 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 - ); - } - ), - 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, - }, - preferences: [], - }; - mockRoutesWithPreferences.push(newRoute); + deleteRoutePreference: jest.fn(async (preferenceId: number) => { + mockRoutesWithPreferences = mockRoutesWithPreferences.map((r) => ({ + ...r, + preferences: r.preferences.filter((p) => p.id !== preferenceId), + })); }), - __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", + coordinates: { latitude: 60.1699, longitude: 24.9384 }, + }, + { + name: "Espoo", + coordinates: { latitude: 60.2055, longitude: 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( - - ); - - 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"); + it("can add a new route", async () => { + const screen = render(); - 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( - - ); + // 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 addNewRouteButton = getByTestId("add-new-route-button"); - fireEvent.press(addNewRouteButton); - - 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(); + // Check that the new route appears in the UI + expect(screen.getByText("Helsinki")).toBeTruthy(); + expect(screen.getByText("Espoo")).toBeTruthy(); }); }); }); 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"); 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", ]); }); }); diff --git a/src/components/RouteSpecificPreferences.tsx b/src/components/RouteSpecificPreferences.tsx index adff23c..d07f66d 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( @@ -141,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); + + 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; + } - // Refetch the routes to get the updated list - await fetchRoutesWithPreferences(); + // 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([]); @@ -163,8 +187,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 +256,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..2061e1c 100644 --- a/src/lib/api/preferences.ts +++ b/src/lib/api/preferences.ts @@ -1,158 +1,59 @@ 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, - }, -]; - -let savedRoutesData: RouteData[] = [...initialRoutesData]; - -const PreferenceSchema = z.object({ - id: z.number(), +// API-specific schemas (only used by this API module) +const NewPreferenceSchema = z.object({ prompt: z.string(), - created_at: z.string().datetime(), - updated_at: z.string().datetime().nullable().optional(), }); -const PreferencesResponseSchema = z.array(PreferenceSchema); - -export type Preference = z.infer; - -export type PreferenceCreate = Omit< - Preference, - "id" | "created_at" | "updated_at" ->; +const RoutePreferenceCreateSchema = z.object({ + prompt: z.string(), + from_latitude: z.number(), + from_longitude: z.number(), + to_latitude: z.number(), + to_longitude: z.number(), +}); -const RoutePreferenceSchema = z.object({ +const RoutePreferenceResponseSchema = 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(), + from_latitude: z.number(), + from_longitude: z.number(), + to_latitude: z.number(), + to_longitude: z.number(), }); -export type RoutePreference = z.infer; - -export interface RouteWithPreferences { - route: Route; - preferences: RoutePreference[]; -} +type RoutePreferenceResponse = z.infer; -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, - }, - ], -}; - -// Use a deep copy for the mutable state -let mockRoutePreferences: Record = JSON.parse( - JSON.stringify(initialMockRoutePreferences) -); -let nextId = 100; +// Helper to create a unique key for route coordinates +const getRouteKey = (from: Coordinates, to: Coordinates) => + `${from.latitude},${from.longitude},${to.latitude},${to.longitude}`; 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,109 +63,119 @@ 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); - }, - - async addRouteSpecificPreference( - route: RouteData, - preference: PreferenceCreate - ): Promise { - const routeKey = getRouteKey(route); - console.log( - `Adding preference to route ${routeKey}: ${preference.prompt}` - ); - 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, - }; - 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}` - ); - if (mockRoutePreferences[routeKey]) { - mockRoutePreferences[routeKey] = mockRoutePreferences[ - routeKey - ].filter((p) => p.id !== preferenceId); + // 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, + }); } - return Promise.resolve(); - }, - - addSavedRoute: async (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; + // 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, + }); } - const newRouteData: RouteData = { - fromLat: fromCoords.lat, - fromLon: fromCoords.lon, - toLat: toCoords.lat, - toLon: toCoords.lon, - }; + return result; + }, - const routeKey = getRouteKey(newRouteData); - const routeExists = savedRoutesData.some( - (route) => getRouteKey(route) === routeKey + 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 ); - if (routeExists) { - return; - } - - savedRoutesData.push(newRouteData); - mockRoutePreferences[routeKey] = []; + // 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, + }; }, - // Test helper to reset the mock state - __resetMocks: () => { - mockRoutePreferences = JSON.parse( - JSON.stringify(initialMockRoutePreferences) + async deleteRoutePreference(preferenceId: number): Promise { + await apiClient.delete( + `/users/route-preferences/${preferenceId}` ); - savedRoutesData = [...initialRoutesData]; - nextId = 100; }, }; 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 { 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;