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;