From c5bf25fe77820b1d2cdd76c25cf43858ef417c33 Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 12 May 2026 18:23:34 +0100 Subject: [PATCH 1/7] fix: add weather location fallbacks --- backend/app/api/weather.py | 19 ++-- backend/app/services/weather_service.py | 38 ++++++++ backend/tests/test_weather_api.py | 67 ++++++++++++++ frontend/app/dashboard/settings/page.tsx | 107 ++++++++++++++++------ frontend/lib/location.ts | 108 +++++++++++++++++++++++ frontend/tests/location.test.ts | 81 +++++++++++++++++ 6 files changed, 385 insertions(+), 35 deletions(-) create mode 100644 backend/tests/test_weather_api.py create mode 100644 frontend/lib/location.ts create mode 100644 frontend/tests/location.test.ts diff --git a/backend/app/api/weather.py b/backend/app/api/weather.py index 665c68ae..c1092f1c 100644 --- a/backend/app/api/weather.py +++ b/backend/app/api/weather.py @@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field - from app.models.user import User from app.services.weather_service import WeatherService, WeatherServiceError from app.utils.auth import get_current_user @@ -54,18 +53,21 @@ async def get_current_weather( latitude: float | None = Query(None, ge=-90, le=90), longitude: float | None = Query(None, ge=-180, le=180), ) -> WeatherResponse: - # Use provided coordinates or fall back to user's location + weather_service = WeatherService() lat = latitude if latitude is not None else current_user.location_lat lon = longitude if longitude is not None else current_user.location_lon + if (lat is None or lon is None) and current_user.location_name: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + if geocoded: + lat, lon, _ = geocoded + if lat is None or lon is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Location not set. Please provide coordinates or set your location in settings.", ) - weather_service = WeatherService() - try: weather = await weather_service.get_current_weather(float(lat), float(lon)) except WeatherServiceError as e: @@ -97,18 +99,21 @@ async def get_weather_forecast( longitude: float | None = Query(None, ge=-180, le=180), days: int = Query(7, ge=1, le=16), ) -> ForecastResponse: - # Use provided coordinates or fall back to user's location + weather_service = WeatherService() lat = latitude if latitude is not None else current_user.location_lat lon = longitude if longitude is not None else current_user.location_lon + if (lat is None or lon is None) and current_user.location_name: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + if geocoded: + lat, lon, _ = geocoded + if lat is None or lon is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Location not set. Please provide coordinates or set your location in settings.", ) - weather_service = WeatherService() - try: forecast = await weather_service.get_daily_forecast(float(lat), float(lon), days) except WeatherServiceError as e: diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index 9ba227f9..bca43550 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -98,6 +98,44 @@ class WeatherService: def __init__(self): self.base_url = settings.openmeteo_url + async def geocode_location_name(self, location_name: str) -> tuple[float, float, str | None] | None: + """Resolve a free-form location name to coordinates using Nominatim.""" + query = location_name.strip() + if not query: + return None + + params = { + "q": query, + "format": "jsonv2", + "limit": 1, + } + + headers = { + "User-Agent": "Wardrowbe/1.0 (local-docker-geocoding)", + } + + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True, headers=headers) as client: + try: + response = await client.get("https://nominatim.openstreetmap.org/search", params=params) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as e: + logger.error(f"Geocoding error for {query!r}: {e}") + return None + + if not data: + return None + + first = data[0] + try: + latitude = float(first["lat"]) + longitude = float(first["lon"]) + except (KeyError, TypeError, ValueError): + return None + + display_name = first.get("display_name") + return latitude, longitude, display_name + @staticmethod def _cache_key(lat: float, lon: float) -> str: return f"{CACHE_PREFIX}{round(lat, 2)},{round(lon, 2)}" diff --git a/backend/tests/test_weather_api.py b/backend/tests/test_weather_api.py new file mode 100644 index 00000000..5ce72a85 --- /dev/null +++ b/backend/tests/test_weather_api.py @@ -0,0 +1,67 @@ +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient + + +class TestWeatherApi: + @pytest.mark.asyncio + async def test_current_weather_uses_location_name_without_persisting_coordinates( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = "New York City" + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + geocode_mock = AsyncMock(return_value=(40.7128, -74.0060, "New York City")) + weather_mock = AsyncMock( + return_value=type( + "Weather", + (), + { + "temperature": 12.5, + "feels_like": 7.3, + "humidity": 50, + "precipitation_chance": 10, + "precipitation_mm": 0.0, + "wind_speed": 23.4, + "condition": "partly cloudy", + "condition_code": 2, + "is_day": True, + "uv_index": 1.8, + "timestamp": datetime(2026, 5, 12, 15, 21, 35), + }, + )() + ) + + with ( + patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock), + patch("app.api.weather.WeatherService.get_current_weather", weather_mock), + ): + response = await client.get("/api/v1/weather/current", headers=auth_headers) + + assert response.status_code == 200 + geocode_mock.assert_awaited_once_with("New York City") + weather_mock.assert_awaited_once_with(40.7128, -74.0060) + + await db_session.refresh(test_user) + assert test_user.location_lat is None + assert test_user.location_lon is None + + @pytest.mark.asyncio + async def test_forecast_returns_400_when_location_missing( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = None + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + response = await client.get("/api/v1/weather/forecast", headers=auth_headers) + + assert response.status_code == 400 + assert response.json()["detail"] == ( + "Location not set. Please provide coordinates or set your location in settings." + ) diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index b5797346..c854c8d4 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -19,6 +19,11 @@ import { import { Badge } from '@/components/ui/badge'; import { usePreferences, useUpdatePreferences, useResetPreferences, useTestAIEndpoint } from '@/lib/hooks/use-preferences'; import { useUserProfile, useUpdateUserProfile } from '@/lib/hooks/use-user'; +import { + formatReverseGeocodedLocation, + getGeolocationFailureMessage, + resolveNetworkLocation, +} from '@/lib/location'; import { CLOTHING_COLORS, OCCASIONS, Preferences, StyleProfile, AIEndpoint } from '@/lib/types'; import { toF, toCelsius } from '@/lib/temperature'; import { toast } from 'sonner'; @@ -27,6 +32,7 @@ const CM_TO_IN = 0.393701; const IN_TO_CM = 2.54; const KG_TO_LBS = 2.20462; const LBS_TO_KG = 0.453592; +const NETWORK_LOCATION_URL = 'https://ipapi.co/json/'; function convertMeasurement(value: number, key: string, from: string, to: string): number { if (from === to) return value; @@ -216,49 +222,94 @@ export default function SettingsPage() { } }, [userProfile]); + const detectLocationFromNetwork = async () => { + const response = await fetch(NETWORK_LOCATION_URL, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error( + `Network-based location lookup failed (${response.status}${response.statusText ? ` ${response.statusText}` : ''})` + ); + } + + const data = await response.json(); + const resolved = resolveNetworkLocation(data, timezone); + setLocationLat(resolved.lat); + setLocationLon(resolved.lon); + if (resolved.locationName) { + setLocationName(resolved.locationName); + } + if (resolved.timezone) { + setTimezone(resolved.timezone); + } + return resolved; + }; + const handleGetCurrentLocation = () => { + setIsGettingLocation(true); + const finalizeFromCoordinates = async (lat: string, lon: string) => { + // Reverse geocode to get city name + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json` + ); + if (response.ok) { + const data = await response.json(); + const nextLocationName = formatReverseGeocodedLocation(data); + if (nextLocationName) { + setLocationName(nextLocationName); + return nextLocationName; + } + } + } catch { + // Ignore geocoding errors, we still have coordinates + } + + return undefined; + }; + + const fallbackToNetworkLocation = async (reason?: string) => { + try { + await detectLocationFromNetwork(); + toast.success( + reason + ? `${reason} Approximate location filled in. Review it, then save.` + : 'Approximate location filled in. Review it, then save.' + ); + } catch (fallbackError) { + const fallbackMessage = fallbackError instanceof Error + ? fallbackError.message + : 'Unable to detect your location'; + toast.error(fallbackMessage); + } finally { + setIsGettingLocation(false); + } + }; + if (!navigator.geolocation) { - toast.error('Geolocation is not supported by your browser'); + void fallbackToNetworkLocation('Geolocation is not supported by your browser.'); return; } - setIsGettingLocation(true); navigator.geolocation.getCurrentPosition( async (position) => { const lat = position.coords.latitude.toFixed(6); const lon = position.coords.longitude.toFixed(6); setLocationLat(lat); setLocationLon(lon); - - // Reverse geocode to get city name - try { - const response = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`, - { headers: { 'User-Agent': 'WardrobeAI/1.0' } } - ); - if (response.ok) { - const data = await response.json(); - const city = data.address?.city || data.address?.town || data.address?.village || data.address?.municipality; - const country = data.address?.country; - if (city && country) { - setLocationName(`${city}, ${country}`); - } else if (city) { - setLocationName(city); - } else if (data.display_name) { - // Fallback to first part of display name - setLocationName(data.display_name.split(',').slice(0, 2).join(',').trim()); - } - } - } catch { - // Ignore geocoding errors, we still have coordinates + const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (detectedTimezone) { + setTimezone(detectedTimezone); } - + await finalizeFromCoordinates(lat, lon); setIsGettingLocation(false); - toast.success('Location detected'); + toast.success('Location detected. Review it, then save.'); }, (error) => { - setIsGettingLocation(false); - toast.error(`Failed to get location: ${error.message}`); + void fallbackToNetworkLocation( + getGeolocationFailureMessage(error) + ); }, { enableHighAccuracy: true, timeout: 10000 } ); diff --git a/frontend/lib/location.ts b/frontend/lib/location.ts new file mode 100644 index 00000000..3c60d3a6 --- /dev/null +++ b/frontend/lib/location.ts @@ -0,0 +1,108 @@ +export interface NetworkLocationApiResponse { + success?: boolean; + latitude?: number; + longitude?: number; + city?: string; + region?: string; + country?: string; + country_name?: string; + timezone?: { + id?: string; + } | string; + error?: boolean; + reason?: string; + message?: string; +} + +export interface ResolvedLocation { + lat: string; + lon: string; + locationName?: string; + timezone?: string; +} + +export interface ReverseGeocodeResponse { + address?: { + city?: string; + town?: string; + village?: string; + municipality?: string; + country?: string; + }; + display_name?: string; +} + +export function resolveNetworkLocation( + data: NetworkLocationApiResponse, + fallbackTimezone?: string +): ResolvedLocation { + if ( + data.success === false || + data.error === true || + typeof data.latitude !== 'number' || + typeof data.longitude !== 'number' + ) { + throw new Error(data.reason || data.message || 'Unable to determine location from network'); + } + + const city = typeof data.city === 'string' ? data.city : ''; + const region = typeof data.region === 'string' ? data.region : ''; + const country = + typeof data.country === 'string' + ? data.country + : typeof data.country_name === 'string' + ? data.country_name + : ''; + const labelParts = [city, region, country].filter(Boolean); + const timezoneId = + typeof data.timezone === 'string' + ? data.timezone + : data.timezone?.id; + + return { + lat: data.latitude.toFixed(6), + lon: data.longitude.toFixed(6), + locationName: labelParts.length > 0 ? labelParts.slice(0, 2).join(', ') : undefined, + timezone: + typeof timezoneId === 'string' && timezoneId + ? timezoneId + : fallbackTimezone, + }; +} + +export function formatReverseGeocodedLocation( + data: ReverseGeocodeResponse +): string | undefined { + const city = + data.address?.city || + data.address?.town || + data.address?.village || + data.address?.municipality; + const country = data.address?.country; + + if (city && country) return `${city}, ${country}`; + if (city) return city; + if (data.display_name) { + return data.display_name.split(',').slice(0, 2).join(',').trim(); + } + return undefined; +} + +export function getGeolocationFailureMessage(error: { + code?: number; + message?: string; +}): string { + const reasons: Record = { + 1: 'Location access was denied.', + 2: 'Location is currently unavailable.', + 3: 'Location request timed out.', + }; + + if (typeof error.code === 'number' && reasons[error.code]) { + return reasons[error.code]; + } + if (error.message) { + return `Failed to get exact location: ${error.message}`; + } + return 'Failed to get exact location.'; +} diff --git a/frontend/tests/location.test.ts b/frontend/tests/location.test.ts new file mode 100644 index 00000000..2c251808 --- /dev/null +++ b/frontend/tests/location.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { + formatReverseGeocodedLocation, + getGeolocationFailureMessage, + resolveNetworkLocation, +} from '@/lib/location' + +describe('location helpers', () => { + it('resolves network location with formatted city label and timezone', () => { + const result = resolveNetworkLocation({ + success: true, + latitude: 40.7128, + longitude: -74.006, + city: 'New York', + region: 'New York', + country: 'United States', + timezone: { id: 'America/New_York' }, + }, 'UTC') + + expect(result).toEqual({ + lat: '40.712800', + lon: '-74.006000', + locationName: 'New York, New York', + timezone: 'America/New_York', + }) + }) + + it('falls back to provided timezone when network response omits one', () => { + const result = resolveNetworkLocation({ + success: true, + latitude: 40.7128, + longitude: -74.006, + }, 'America/New_York') + + expect(result.timezone).toBe('America/New_York') + }) + + it('supports alternate provider response shapes', () => { + const result = resolveNetworkLocation({ + latitude: 37.7749, + longitude: -122.4194, + city: 'San Francisco', + region: 'California', + country_name: 'United States', + timezone: 'America/Los_Angeles', + }, 'UTC') + + expect(result).toEqual({ + lat: '37.774900', + lon: '-122.419400', + locationName: 'San Francisco, California', + timezone: 'America/Los_Angeles', + }) + }) + + it('throws when network location is incomplete', () => { + expect(() => resolveNetworkLocation({ success: false }, 'UTC')).toThrow( + 'Unable to determine location from network' + ) + }) + + it('formats reverse geocoding responses consistently', () => { + expect(formatReverseGeocodedLocation({ + address: { city: 'London', country: 'United Kingdom' }, + })).toBe('London, United Kingdom') + + expect(formatReverseGeocodedLocation({ + display_name: 'Paris, Ile-de-France, France', + })).toBe('Paris, Ile-de-France') + }) + + it('maps geolocation failure reasons to user-facing messages', () => { + expect(getGeolocationFailureMessage({ code: 1 })).toBe('Location access was denied.') + expect(getGeolocationFailureMessage({ code: 2 })).toBe('Location is currently unavailable.') + expect(getGeolocationFailureMessage({ code: 3 })).toBe('Location request timed out.') + expect(getGeolocationFailureMessage({ message: 'Permission prompt dismissed' })).toBe( + 'Failed to get exact location: Permission prompt dismissed' + ) + }) +}) From a9b5d93333d7341e900d3e3da6451eccbb257bf8 Mon Sep 17 00:00:00 2001 From: Gianni-Zhang <120421065+gzhang33@users.noreply.github.com> Date: Tue, 12 May 2026 18:42:40 +0100 Subject: [PATCH 2/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/app/services/weather_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index bca43550..d3b6c59c 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -119,7 +119,7 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, response = await client.get("https://nominatim.openstreetmap.org/search", params=params) response.raise_for_status() data = response.json() - except httpx.HTTPError as e: + except (httpx.HTTPError, ValueError) as e: logger.error(f"Geocoding error for {query!r}: {e}") return None From 135707d9d1506af0ca86e7d6b663d5b01f5af3eb Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 12 May 2026 19:30:46 +0100 Subject: [PATCH 3/7] fix: handle geocoding failures explicitly --- backend/app/api/weather.py | 24 +++++++++++++-- backend/app/services/weather_service.py | 12 +++++++- backend/tests/test_weather_api.py | 39 +++++++++++++++++++++++++ backend/tests/test_weather_service.py | 14 +++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/backend/app/api/weather.py b/backend/app/api/weather.py index c1092f1c..66e09253 100644 --- a/backend/app/api/weather.py +++ b/backend/app/api/weather.py @@ -4,13 +4,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from app.models.user import User -from app.services.weather_service import WeatherService, WeatherServiceError +from app.services.weather_service import GeocodingServiceError, WeatherService, WeatherServiceError from app.utils.auth import get_current_user logger = logging.getLogger(__name__) router = APIRouter(prefix="/weather", tags=["Weather"]) +GEOCODING_FAILURE_DETAIL = ( + "Unable to geocode saved location name. Please try again later or update your location in settings." +) + class WeatherResponse(BaseModel): temperature: float = Field(description="Temperature in Celsius") @@ -58,7 +62,14 @@ async def get_current_weather( lon = longitude if longitude is not None else current_user.location_lon if (lat is None or lon is None) and current_user.location_name: - geocoded = await weather_service.geocode_location_name(current_user.location_name) + try: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + except GeocodingServiceError as e: + logger.error(f"Geocoding service error: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=GEOCODING_FAILURE_DETAIL, + ) from None if geocoded: lat, lon, _ = geocoded @@ -104,7 +115,14 @@ async def get_weather_forecast( lon = longitude if longitude is not None else current_user.location_lon if (lat is None or lon is None) and current_user.location_name: - geocoded = await weather_service.geocode_location_name(current_user.location_name) + try: + geocoded = await weather_service.geocode_location_name(current_user.location_name) + except GeocodingServiceError as e: + logger.error(f"Geocoding service error: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=GEOCODING_FAILURE_DETAIL, + ) from None if geocoded: lat, lon, _ = geocoded diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index d3b6c59c..3e0588dc 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -2,6 +2,7 @@ import logging from dataclasses import dataclass from datetime import datetime +from json import JSONDecodeError import httpx import redis.asyncio as aioredis @@ -121,7 +122,12 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, data = response.json() except (httpx.HTTPError, ValueError) as e: logger.error(f"Geocoding error for {query!r}: {e}") - return None + raise GeocodingServiceError(f"Failed to geocode location {query!r}: {e}") from None + except (JSONDecodeError, ValueError) as e: + logger.error(f"Geocoding returned invalid JSON for {query!r}: {e}") + raise GeocodingServiceError( + f"Failed to decode geocoding response for location {query!r}: {e}" + ) from None if not data: return None @@ -387,3 +393,7 @@ async def check_health(self) -> dict: class WeatherServiceError(Exception): pass + + +class GeocodingServiceError(WeatherServiceError): + pass diff --git a/backend/tests/test_weather_api.py b/backend/tests/test_weather_api.py index 5ce72a85..3d6f4778 100644 --- a/backend/tests/test_weather_api.py +++ b/backend/tests/test_weather_api.py @@ -4,6 +4,9 @@ import pytest from httpx import AsyncClient +from app.api.weather import GEOCODING_FAILURE_DETAIL +from app.services.weather_service import GeocodingServiceError + class TestWeatherApi: @pytest.mark.asyncio @@ -65,3 +68,39 @@ async def test_forecast_returns_400_when_location_missing( assert response.json()["detail"] == ( "Location not set. Please provide coordinates or set your location in settings." ) + + @pytest.mark.asyncio + async def test_current_weather_returns_503_when_saved_location_geocoding_fails( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = "New York City" + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + geocode_mock = AsyncMock(side_effect=GeocodingServiceError("geocoder unavailable")) + + with patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock): + response = await client.get("/api/v1/weather/current", headers=auth_headers) + + assert response.status_code == 503 + assert response.json()["detail"] == GEOCODING_FAILURE_DETAIL + geocode_mock.assert_awaited_once_with("New York City") + + @pytest.mark.asyncio + async def test_forecast_returns_503_when_saved_location_geocoding_fails( + self, client: AsyncClient, test_user, auth_headers, db_session + ): + test_user.location_name = "New York City" + test_user.location_lat = None + test_user.location_lon = None + await db_session.commit() + + geocode_mock = AsyncMock(side_effect=GeocodingServiceError("geocoder unavailable")) + + with patch("app.api.weather.WeatherService.geocode_location_name", geocode_mock): + response = await client.get("/api/v1/weather/forecast", headers=auth_headers) + + assert response.status_code == 503 + assert response.json()["detail"] == GEOCODING_FAILURE_DETAIL + geocode_mock.assert_awaited_once_with("New York City") diff --git a/backend/tests/test_weather_service.py b/backend/tests/test_weather_service.py index fc1804ed..40dd7fea 100644 --- a/backend/tests/test_weather_service.py +++ b/backend/tests/test_weather_service.py @@ -8,6 +8,7 @@ from app.services.weather_service import ( CACHE_PREFIX, + GeocodingServiceError, WMO_CODES, WeatherData, WeatherService, @@ -237,6 +238,19 @@ async def test_handles_missing_hourly_precipitation(self, weather_service, mock_ assert result.precipitation_chance == 0 +class TestGeocodeLocationName: + @pytest.mark.asyncio + async def test_raises_on_invalid_json_response(self, weather_service): + request = httpx.Request("GET", "https://nominatim.openstreetmap.org/search") + mock_response = httpx.Response(200, text="rate limited", request=request) + + with patch("httpx.AsyncClient.get", return_value=mock_response): + with pytest.raises( + GeocodingServiceError, match="Failed to decode geocoding response" + ): + await weather_service.geocode_location_name("New York City") + + class TestGetDailyForecast: @pytest.mark.asyncio async def test_returns_forecast_days(self, weather_service, mock_redis): From fe3381c80d8801dfab30294a748c49453d02f374 Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 12 May 2026 20:01:55 +0100 Subject: [PATCH 4/7] refactor: centralize geocoding and location provider config --- .env.example | 8 +++++++ backend/app/config.py | 12 ++++++++++ backend/app/main.py | 2 +- backend/app/services/weather_service.py | 2 +- backend/tests/test_config.py | 23 +++++++++++++++++++ frontend/app/dashboard/settings/page.tsx | 4 ++-- frontend/lib/location.ts | 6 +++++ frontend/tests/location.test.ts | 28 ++++++++++++++++++++++++ 8 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_config.py diff --git a/.env.example b/.env.example index ad962404..73505317 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,12 @@ SECRET_KEY=change-me-in-production # Uncomment to enable dev credential login (no OIDC required). Requires the # SECRET_KEY above to remain "change-me-in-production". # DEBUG=true +# Weather/geocoding integrations +# Optional override for the Nominatim User-Agent header. If unset, Wardrowbe +# sends "${APP_NAME}/${APP_VERSION}" and appends GEOCODING_CONTACT when present. +# GEOCODING_USER_AGENT=Wardrowbe/1.0.0 (https://example.com/contact) +# Optional contact URL or email to append to the default geocoding User-Agent. +# GEOCODING_CONTACT=https://example.com/contact # Frontend Configuration # ============================================================================ @@ -33,6 +39,8 @@ FRONTEND_PORT=3000 NEXTAUTH_URL=http://localhost:3000 # IMPORTANT: Generate a secure secret for production NEXTAUTH_SECRET=change-me-in-production-use-openssl-rand-hex-32 +# Optional override for browser-side approximate location provider. +# NEXT_PUBLIC_NETWORK_LOCATION_URL=https://ipapi.co/json/ # Authentication (OIDC Provider) # ============================================================================ diff --git a/backend/app/config.py b/backend/app/config.py index e8846819..410b2233 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,6 +19,7 @@ class Settings(BaseSettings): # Application app_name: str = "Wardrowbe" + app_version: str = "1.0.0" debug: bool = False secret_key: str = Field(default=DEFAULT_SECRET_KEY) studio_disabled: bool = False @@ -52,6 +53,8 @@ class Settings(BaseSettings): # Weather openmeteo_url: str = Field(default="https://api.open-meteo.com/v1") + geocoding_user_agent: str | None = Field(default=None) + geocoding_contact: str | None = Field(default=None) # Notifications - default ntfy channel (used when user has none configured) ntfy_server: str | None = None @@ -110,6 +113,15 @@ def get_auth_mode(self) -> str: return "oidc" return "unknown" + def get_geocoding_user_agent(self) -> str: + if self.geocoding_user_agent: + return self.geocoding_user_agent + + base = f"{self.app_name}/{self.app_version}" + if self.geocoding_contact: + return f"{base} ({self.geocoding_contact})" + return base + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/main.py b/backend/app/main.py index 9a4fce3d..a1a44363 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,7 +30,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = FastAPI( title=settings.app_name, description="AI-powered wardrobe management and outfit recommendations", - version="1.0.0", + version=settings.app_version, lifespan=lifespan, docs_url="/docs" if settings.debug else None, redoc_url="/redoc" if settings.debug else None, diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index 3e0588dc..738e65a6 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -112,7 +112,7 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, } headers = { - "User-Agent": "Wardrowbe/1.0 (local-docker-geocoding)", + "User-Agent": settings.get_geocoding_user_agent(), } async with httpx.AsyncClient(timeout=10.0, follow_redirects=True, headers=headers) as client: diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 00000000..ecbfb1e0 --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,23 @@ +from app.config import Settings + + +class TestGeocodingUserAgent: + def test_uses_explicit_user_agent_when_configured(self): + settings = Settings( + geocoding_user_agent="WardrowbeCustom/2.0 (+https://example.com/contact)" + ) + + assert settings.get_geocoding_user_agent() == ( + "WardrowbeCustom/2.0 (+https://example.com/contact)" + ) + + def test_builds_user_agent_from_app_metadata_and_contact(self): + settings = Settings( + app_name="Wardrowbe", + app_version="1.2.3", + geocoding_contact="https://example.com/contact", + ) + + assert settings.get_geocoding_user_agent() == ( + "Wardrowbe/1.2.3 (https://example.com/contact)" + ) diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index c854c8d4..9b3bb1b5 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -20,6 +20,7 @@ import { Badge } from '@/components/ui/badge'; import { usePreferences, useUpdatePreferences, useResetPreferences, useTestAIEndpoint } from '@/lib/hooks/use-preferences'; import { useUserProfile, useUpdateUserProfile } from '@/lib/hooks/use-user'; import { + getNetworkLocationUrl, formatReverseGeocodedLocation, getGeolocationFailureMessage, resolveNetworkLocation, @@ -32,7 +33,6 @@ const CM_TO_IN = 0.393701; const IN_TO_CM = 2.54; const KG_TO_LBS = 2.20462; const LBS_TO_KG = 0.453592; -const NETWORK_LOCATION_URL = 'https://ipapi.co/json/'; function convertMeasurement(value: number, key: string, from: string, to: string): number { if (from === to) return value; @@ -223,7 +223,7 @@ export default function SettingsPage() { }, [userProfile]); const detectLocationFromNetwork = async () => { - const response = await fetch(NETWORK_LOCATION_URL, { + const response = await fetch(getNetworkLocationUrl(), { headers: { Accept: 'application/json' }, }); diff --git a/frontend/lib/location.ts b/frontend/lib/location.ts index 3c60d3a6..a3e283bb 100644 --- a/frontend/lib/location.ts +++ b/frontend/lib/location.ts @@ -32,6 +32,12 @@ export interface ReverseGeocodeResponse { display_name?: string; } +export const DEFAULT_NETWORK_LOCATION_URL = 'https://ipapi.co/json/'; + +export function getNetworkLocationUrl(): string { + return process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL || DEFAULT_NETWORK_LOCATION_URL; +} + export function resolveNetworkLocation( data: NetworkLocationApiResponse, fallbackTimezone?: string diff --git a/frontend/tests/location.test.ts b/frontend/tests/location.test.ts index 2c251808..865b77fc 100644 --- a/frontend/tests/location.test.ts +++ b/frontend/tests/location.test.ts @@ -1,12 +1,40 @@ import { describe, expect, it } from 'vitest' import { + DEFAULT_NETWORK_LOCATION_URL, formatReverseGeocodedLocation, + getNetworkLocationUrl, getGeolocationFailureMessage, resolveNetworkLocation, } from '@/lib/location' describe('location helpers', () => { + it('uses the default network provider URL when no env override is set', () => { + const original = process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + delete process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + + expect(getNetworkLocationUrl()).toBe(DEFAULT_NETWORK_LOCATION_URL) + + if (original === undefined) { + delete process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + } else { + process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL = original + } + }) + + it('uses the configured network provider URL when provided', () => { + const original = process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL = 'https://geo.example.com/json' + + expect(getNetworkLocationUrl()).toBe('https://geo.example.com/json') + + if (original === undefined) { + delete process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL + } else { + process.env.NEXT_PUBLIC_NETWORK_LOCATION_URL = original + } + }) + it('resolves network location with formatted city label and timezone', () => { const result = resolveNetworkLocation({ success: true, From cd19b6bd1f7ceee36cb129a3f7ad949d03fa2f11 Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 12 May 2026 20:49:52 +0100 Subject: [PATCH 5/7] fix: clean up PR validation issues --- backend/app/services/weather_service.py | 5 ++--- frontend/app/dashboard/settings/page.tsx | 9 +++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py index 738e65a6..a8e50fe9 100644 --- a/backend/app/services/weather_service.py +++ b/backend/app/services/weather_service.py @@ -2,7 +2,6 @@ import logging from dataclasses import dataclass from datetime import datetime -from json import JSONDecodeError import httpx import redis.asyncio as aioredis @@ -120,10 +119,10 @@ async def geocode_location_name(self, location_name: str) -> tuple[float, float, response = await client.get("https://nominatim.openstreetmap.org/search", params=params) response.raise_for_status() data = response.json() - except (httpx.HTTPError, ValueError) as e: + except httpx.HTTPError as e: logger.error(f"Geocoding error for {query!r}: {e}") raise GeocodingServiceError(f"Failed to geocode location {query!r}: {e}") from None - except (JSONDecodeError, ValueError) as e: + except ValueError as e: logger.error(f"Geocoding returned invalid JSON for {query!r}: {e}") raise GeocodingServiceError( f"Failed to decode geocoding response for location {query!r}: {e}" diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index 9b3bb1b5..d239075c 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useSession } from 'next-auth/react'; import { Loader2, Save, RotateCcw, Check, Plus, Trash2, ChevronUp, ChevronDown, Server, MapPin, Navigation, Ruler } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -197,7 +197,11 @@ export default function SettingsPage() { } return 'metric'; }); + const unitSystemRef = useRef(unitSystem); + useEffect(() => { + unitSystemRef.current = unitSystem; + }, [unitSystem]); useEffect(() => { if (userProfile) { @@ -209,9 +213,10 @@ export default function SettingsPage() { if (userProfile.body_measurements) { const initial: Record = {}; const numericKeys = ['chest', 'waist', 'hips', 'inseam', 'height', 'weight']; + const displayUnitSystem = unitSystemRef.current; for (const [key, value] of Object.entries(userProfile.body_measurements)) { if (numericKeys.includes(key) && typeof value === 'number') { - const converted = convertMeasurement(value, key, 'metric', unitSystem); + const converted = convertMeasurement(value, key, 'metric', displayUnitSystem); initial[key] = String(converted); } else { initial[key] = String(value); From 5e0d67854661bb227f9fd2ee0cef7a6e275e28d1 Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 12 May 2026 21:47:47 +0100 Subject: [PATCH 6/7] chore: remove env example overrides --- .env.example | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.env.example b/.env.example index 73505317..ad962404 100644 --- a/.env.example +++ b/.env.example @@ -26,12 +26,6 @@ SECRET_KEY=change-me-in-production # Uncomment to enable dev credential login (no OIDC required). Requires the # SECRET_KEY above to remain "change-me-in-production". # DEBUG=true -# Weather/geocoding integrations -# Optional override for the Nominatim User-Agent header. If unset, Wardrowbe -# sends "${APP_NAME}/${APP_VERSION}" and appends GEOCODING_CONTACT when present. -# GEOCODING_USER_AGENT=Wardrowbe/1.0.0 (https://example.com/contact) -# Optional contact URL or email to append to the default geocoding User-Agent. -# GEOCODING_CONTACT=https://example.com/contact # Frontend Configuration # ============================================================================ @@ -39,8 +33,6 @@ FRONTEND_PORT=3000 NEXTAUTH_URL=http://localhost:3000 # IMPORTANT: Generate a secure secret for production NEXTAUTH_SECRET=change-me-in-production-use-openssl-rand-hex-32 -# Optional override for browser-side approximate location provider. -# NEXT_PUBLIC_NETWORK_LOCATION_URL=https://ipapi.co/json/ # Authentication (OIDC Provider) # ============================================================================ From 8437fe53418ac9551636f6fa9af9904d274a3714 Mon Sep 17 00:00:00 2001 From: apple Date: Tue, 12 May 2026 22:00:39 +0100 Subject: [PATCH 7/7] chore: trim weather fallback diff --- backend/app/config.py | 10 +--------- backend/app/main.py | 2 +- backend/tests/test_config.py | 23 ----------------------- frontend/app/dashboard/settings/page.tsx | 4 ---- 4 files changed, 2 insertions(+), 37 deletions(-) delete mode 100644 backend/tests/test_config.py diff --git a/backend/app/config.py b/backend/app/config.py index 410b2233..03fbaf8d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,6 @@ class Settings(BaseSettings): # Application app_name: str = "Wardrowbe" - app_version: str = "1.0.0" debug: bool = False secret_key: str = Field(default=DEFAULT_SECRET_KEY) studio_disabled: bool = False @@ -54,7 +53,6 @@ class Settings(BaseSettings): # Weather openmeteo_url: str = Field(default="https://api.open-meteo.com/v1") geocoding_user_agent: str | None = Field(default=None) - geocoding_contact: str | None = Field(default=None) # Notifications - default ntfy channel (used when user has none configured) ntfy_server: str | None = None @@ -114,13 +112,7 @@ def get_auth_mode(self) -> str: return "unknown" def get_geocoding_user_agent(self) -> str: - if self.geocoding_user_agent: - return self.geocoding_user_agent - - base = f"{self.app_name}/{self.app_version}" - if self.geocoding_contact: - return f"{base} ({self.geocoding_contact})" - return base + return self.geocoding_user_agent or "Wardrowbe/1.0" @lru_cache diff --git a/backend/app/main.py b/backend/app/main.py index a1a44363..9a4fce3d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,7 +30,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = FastAPI( title=settings.app_name, description="AI-powered wardrobe management and outfit recommendations", - version=settings.app_version, + version="1.0.0", lifespan=lifespan, docs_url="/docs" if settings.debug else None, redoc_url="/redoc" if settings.debug else None, diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py deleted file mode 100644 index ecbfb1e0..00000000 --- a/backend/tests/test_config.py +++ /dev/null @@ -1,23 +0,0 @@ -from app.config import Settings - - -class TestGeocodingUserAgent: - def test_uses_explicit_user_agent_when_configured(self): - settings = Settings( - geocoding_user_agent="WardrowbeCustom/2.0 (+https://example.com/contact)" - ) - - assert settings.get_geocoding_user_agent() == ( - "WardrowbeCustom/2.0 (+https://example.com/contact)" - ) - - def test_builds_user_agent_from_app_metadata_and_contact(self): - settings = Settings( - app_name="Wardrowbe", - app_version="1.2.3", - geocoding_contact="https://example.com/contact", - ) - - assert settings.get_geocoding_user_agent() == ( - "Wardrowbe/1.2.3 (https://example.com/contact)" - ) diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index d239075c..305c2d0c 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -303,10 +303,6 @@ export default function SettingsPage() { const lon = position.coords.longitude.toFixed(6); setLocationLat(lat); setLocationLon(lon); - const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (detectedTimezone) { - setTimezone(detectedTimezone); - } await finalizeFromCoordinates(lat, lon); setIsGettingLocation(false); toast.success('Location detected. Review it, then save.');