diff --git a/backend/alembic/versions/005_add_weather_coordinates.py b/backend/alembic/versions/005_add_weather_coordinates.py new file mode 100644 index 0000000..55ec7f5 --- /dev/null +++ b/backend/alembic/versions/005_add_weather_coordinates.py @@ -0,0 +1,28 @@ +"""Add weather_lat and weather_lon to settings + +Revision ID: 005 +Revises: 004 +Create Date: 2026-02-21 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '005' +down_revision: Union[str, None] = '004' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('settings', sa.Column('weather_lat', sa.Float(), nullable=True)) + op.add_column('settings', sa.Column('weather_lon', sa.Float(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('settings', 'weather_lon') + op.drop_column('settings', 'weather_lat') diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index e48bab5..361741e 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Integer, func +from sqlalchemy import String, Integer, Float, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from app.database import Base @@ -13,5 +13,7 @@ class Settings(Base): upcoming_days: Mapped[int] = mapped_column(Integer, default=7) preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None) weather_city: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None) + weather_lat: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) + weather_lon: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/weather.py b/backend/app/routers/weather.py index 45eabfc..9a6ec4d 100644 --- a/backend/app/routers/weather.py +++ b/backend/app/routers/weather.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timedelta @@ -23,21 +23,58 @@ def _fetch_json(url: str) -> dict: return json.loads(resp.read().decode()) +@router.get("/search") +async def search_locations( + q: str = Query(..., min_length=1, max_length=100), + current_user: Settings = Depends(get_current_session) +): + api_key = app_settings.OPENWEATHERMAP_API_KEY + if not api_key: + raise HTTPException(status_code=500, detail="Weather API key not configured") + + try: + loop = asyncio.get_event_loop() + url = f"http://api.openweathermap.org/geo/1.0/direct?q={urllib.parse.quote(q)}&limit=5&appid={api_key}" + results = await loop.run_in_executor(None, _fetch_json, url) + + return [ + { + "name": r.get("name", ""), + "state": r.get("state", ""), + "country": r.get("country", ""), + "lat": r.get("lat"), + "lon": r.get("lon"), + } + for r in results + ] + except urllib.error.URLError: + raise HTTPException(status_code=502, detail="Geocoding service unavailable") + except (KeyError, json.JSONDecodeError): + raise HTTPException(status_code=502, detail="Invalid geocoding data") + + @router.get("/") async def get_weather( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - # Get city from settings + # Get settings result = await db.execute(select(Settings)) settings_row = result.scalar_one_or_none() city = settings_row.weather_city if settings_row else None - if not city: - raise HTTPException(status_code=400, detail="No weather city configured") + lat = settings_row.weather_lat if settings_row else None + lon = settings_row.weather_lon if settings_row else None - # Check cache (also invalidate if city changed) + if not city and (lat is None or lon is None): + raise HTTPException(status_code=400, detail="No weather location configured") + + # Build cache key from coordinates or city + use_coords = lat is not None and lon is not None + cache_key = f"{lat},{lon}" if use_coords else city + + # Check cache now = datetime.now() - if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("city") == city: + if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key: return _cache["data"] api_key = app_settings.OPENWEATHERMAP_API_KEY @@ -47,9 +84,14 @@ async def get_weather( try: loop = asyncio.get_event_loop() - # Current weather + forecast in parallel via thread pool - current_url = f"https://api.openweathermap.org/data/2.5/weather?q={urllib.parse.quote(city)}&units=metric&appid={api_key}" - forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?q={urllib.parse.quote(city)}&units=metric&cnt=8&appid={api_key}" + # Build query params based on coordinates or city name + if use_coords: + location_params = f"lat={lat}&lon={lon}" + else: + location_params = f"q={urllib.parse.quote(city)}" + + current_url = f"https://api.openweathermap.org/data/2.5/weather?{location_params}&units=metric&appid={api_key}" + forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?{location_params}&units=metric&cnt=8&appid={api_key}" current_data, forecast_data = await asyncio.gather( loop.run_in_executor(None, _fetch_json, current_url), @@ -76,7 +118,7 @@ async def get_weather( # Cache for 1 hour _cache["data"] = weather_result _cache["expires_at"] = now + timedelta(hours=1) - _cache["city"] = city + _cache["cache_key"] = cache_key return weather_result diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 9951b74..cea6a26 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -27,6 +27,8 @@ class SettingsUpdate(BaseModel): upcoming_days: int | None = None preferred_name: str | None = None weather_city: str | None = None + weather_lat: float | None = None + weather_lon: float | None = None class SettingsResponse(BaseModel): @@ -35,6 +37,8 @@ class SettingsResponse(BaseModel): upcoming_days: int preferred_name: str | None = None weather_city: str | None = None + weather_lat: float | None = None + weather_lon: float | None = None created_at: datetime updated_at: datetime diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index d8e0d43..fde4658 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -1,6 +1,7 @@ -import { useState, FormEvent, CSSProperties } from 'react'; +import { useState, useEffect, useRef, useCallback, FormEvent, CSSProperties } from 'react'; import { toast } from 'sonner'; import { useQueryClient } from '@tanstack/react-query'; +import { MapPin, X, Search, Loader2 } from 'lucide-react'; import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -8,6 +9,8 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; +import api from '@/lib/api'; +import type { GeoLocation } from '@/types'; const accentColors = [ { name: 'cyan', label: 'Cyan', color: '#06b6d4' }, @@ -23,13 +26,88 @@ export default function SettingsPage() { const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan'); const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7); const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? ''); - const [weatherCity, setWeatherCity] = useState(settings?.weather_city ?? ''); + const [locationQuery, setLocationQuery] = useState(''); + const [locationResults, setLocationResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); + const searchRef = useRef(null); + const debounceRef = useRef>(); const [pinForm, setPinForm] = useState({ oldPin: '', newPin: '', confirmPin: '', }); + const hasLocation = settings?.weather_lat != null && settings?.weather_lon != null; + + const searchLocations = useCallback(async (query: string) => { + if (query.length < 2) { + setLocationResults([]); + setShowDropdown(false); + return; + } + setIsSearching(true); + try { + const { data } = await api.get('/weather/search', { params: { q: query } }); + setLocationResults(data); + setShowDropdown(data.length > 0); + } catch { + setLocationResults([]); + setShowDropdown(false); + } finally { + setIsSearching(false); + } + }, []); + + const handleLocationInputChange = (value: string) => { + setLocationQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => searchLocations(value), 300); + }; + + const handleLocationSelect = async (loc: GeoLocation) => { + const displayName = [loc.name, loc.state, loc.country].filter(Boolean).join(', '); + setShowDropdown(false); + setLocationQuery(''); + setLocationResults([]); + try { + await updateSettings({ + weather_city: displayName, + weather_lat: loc.lat, + weather_lon: loc.lon, + }); + queryClient.invalidateQueries({ queryKey: ['weather'] }); + toast.success(`Weather location set to ${displayName}`); + } catch { + toast.error('Failed to update weather location'); + } + }; + + const handleLocationClear = async () => { + try { + await updateSettings({ + weather_city: null, + weather_lat: null, + weather_lon: null, + }); + queryClient.invalidateQueries({ queryKey: ['weather'] }); + toast.success('Weather location cleared'); + } catch { + toast.error('Failed to clear weather location'); + } + }; + + // Close dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + const handleNameSave = async () => { const trimmed = preferredName.trim(); if (trimmed === (settings?.preferred_name || '')) return; @@ -41,18 +119,6 @@ export default function SettingsPage() { } }; - const handleWeatherCitySave = async () => { - const trimmed = weatherCity.trim(); - if (trimmed === (settings?.weather_city || '')) return; - try { - await updateSettings({ weather_city: trimmed || null }); - queryClient.invalidateQueries({ queryKey: ['weather'] }); - toast.success('Weather city updated'); - } catch (error) { - toast.error('Failed to update weather city'); - } - }; - const handleColorChange = async (color: string) => { setSelectedColor(color); try { @@ -201,22 +267,67 @@ export default function SettingsPage() {
- -
- setWeatherCity(e.target.value)} - onBlur={handleWeatherCitySave} - onKeyDown={(e) => { if (e.key === 'Enter') handleWeatherCitySave(); }} - className="max-w-xs" - maxLength={100} - /> -
+ + {hasLocation ? ( +
+ + + {settings?.weather_city} + + +
+ ) : ( +
+
+ + handleLocationInputChange(e.target.value)} + onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }} + className="pl-9 pr-9" + /> + {isSearching && ( + + )} +
+ {showDropdown && ( +
+ {locationResults.map((loc, i) => { + const label = [loc.name, loc.state, loc.country].filter(Boolean).join(', '); + return ( + + ); + })} +
+ )} +
+ )}

- City name for the weather widget. Requires an OpenWeatherMap API key in the server environment. + Search and select your city for accurate weather data on the dashboard.

diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b746493..64fd07d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -4,10 +4,20 @@ export interface Settings { upcoming_days: number; preferred_name?: string | null; weather_city?: string | null; + weather_lat?: number | null; + weather_lon?: number | null; created_at: string; updated_at: string; } +export interface GeoLocation { + name: string; + state: string; + country: string; + lat: number; + lon: number; +} + export interface Todo { id: number; title: string;