Add coordinate-based weather lookup with location search
Replace plain-text city input with geocoding search that resolves lat/lon coordinates for accurate OpenWeatherMap queries. Users can now search, see multiple results with state/country detail, and select the exact location. - Add GET /api/weather/search endpoint (OWM Geocoding API) - Add weather_lat/weather_lon columns to settings model + migration - Use lat/lon for weather API calls when available, fall back to city name - Replace settings text input with debounced search + dropdown selector - Show selected location as chip with clear button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
97242ee928
commit
1545da48e5
28
backend/alembic/versions/005_add_weather_coordinates.py
Normal file
28
backend/alembic/versions/005_add_weather_coordinates.py
Normal file
@ -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')
|
||||||
@ -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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@ -13,5 +13,7 @@ class Settings(Base):
|
|||||||
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
|
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
|
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_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())
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@ -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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -23,21 +23,58 @@ def _fetch_json(url: str) -> dict:
|
|||||||
return json.loads(resp.read().decode())
|
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("/")
|
@router.get("/")
|
||||||
async def get_weather(
|
async def get_weather(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: Settings = Depends(get_current_session)
|
current_user: Settings = Depends(get_current_session)
|
||||||
):
|
):
|
||||||
# Get city from settings
|
# Get settings
|
||||||
result = await db.execute(select(Settings))
|
result = await db.execute(select(Settings))
|
||||||
settings_row = result.scalar_one_or_none()
|
settings_row = result.scalar_one_or_none()
|
||||||
city = settings_row.weather_city if settings_row else None
|
city = settings_row.weather_city if settings_row else None
|
||||||
if not city:
|
lat = settings_row.weather_lat if settings_row else None
|
||||||
raise HTTPException(status_code=400, detail="No weather city configured")
|
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()
|
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"]
|
return _cache["data"]
|
||||||
|
|
||||||
api_key = app_settings.OPENWEATHERMAP_API_KEY
|
api_key = app_settings.OPENWEATHERMAP_API_KEY
|
||||||
@ -47,9 +84,14 @@ async def get_weather(
|
|||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
# Current weather + forecast in parallel via thread pool
|
# Build query params based on coordinates or city name
|
||||||
current_url = f"https://api.openweathermap.org/data/2.5/weather?q={urllib.parse.quote(city)}&units=metric&appid={api_key}"
|
if use_coords:
|
||||||
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?q={urllib.parse.quote(city)}&units=metric&cnt=8&appid={api_key}"
|
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(
|
current_data, forecast_data = await asyncio.gather(
|
||||||
loop.run_in_executor(None, _fetch_json, current_url),
|
loop.run_in_executor(None, _fetch_json, current_url),
|
||||||
@ -76,7 +118,7 @@ async def get_weather(
|
|||||||
# Cache for 1 hour
|
# Cache for 1 hour
|
||||||
_cache["data"] = weather_result
|
_cache["data"] = weather_result
|
||||||
_cache["expires_at"] = now + timedelta(hours=1)
|
_cache["expires_at"] = now + timedelta(hours=1)
|
||||||
_cache["city"] = city
|
_cache["cache_key"] = cache_key
|
||||||
|
|
||||||
return weather_result
|
return weather_result
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,8 @@ class SettingsUpdate(BaseModel):
|
|||||||
upcoming_days: int | None = None
|
upcoming_days: int | None = None
|
||||||
preferred_name: str | None = None
|
preferred_name: str | None = None
|
||||||
weather_city: str | None = None
|
weather_city: str | None = None
|
||||||
|
weather_lat: float | None = None
|
||||||
|
weather_lon: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class SettingsResponse(BaseModel):
|
class SettingsResponse(BaseModel):
|
||||||
@ -35,6 +37,8 @@ class SettingsResponse(BaseModel):
|
|||||||
upcoming_days: int
|
upcoming_days: int
|
||||||
preferred_name: str | None = None
|
preferred_name: str | None = None
|
||||||
weather_city: str | None = None
|
weather_city: str | None = None
|
||||||
|
weather_lat: float | None = None
|
||||||
|
weather_lon: float | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, FormEvent, CSSProperties } from 'react';
|
import { useState, useEffect, useRef, useCallback, FormEvent, CSSProperties } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { MapPin, X, Search, Loader2 } from 'lucide-react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -8,6 +9,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { GeoLocation } from '@/types';
|
||||||
|
|
||||||
const accentColors = [
|
const accentColors = [
|
||||||
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
|
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
|
||||||
@ -23,13 +26,88 @@ export default function SettingsPage() {
|
|||||||
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
||||||
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
||||||
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
|
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
|
||||||
const [weatherCity, setWeatherCity] = useState(settings?.weather_city ?? '');
|
const [locationQuery, setLocationQuery] = useState('');
|
||||||
|
const [locationResults, setLocationResults] = useState<GeoLocation[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const [pinForm, setPinForm] = useState({
|
const [pinForm, setPinForm] = useState({
|
||||||
oldPin: '',
|
oldPin: '',
|
||||||
newPin: '',
|
newPin: '',
|
||||||
confirmPin: '',
|
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<GeoLocation[]>('/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 handleNameSave = async () => {
|
||||||
const trimmed = preferredName.trim();
|
const trimmed = preferredName.trim();
|
||||||
if (trimmed === (settings?.preferred_name || '')) return;
|
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) => {
|
const handleColorChange = async (color: string) => {
|
||||||
setSelectedColor(color);
|
setSelectedColor(color);
|
||||||
try {
|
try {
|
||||||
@ -201,22 +267,67 @@ export default function SettingsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="weather_city">City</Label>
|
<Label>Location</Label>
|
||||||
<div className="flex gap-3 items-center">
|
{hasLocation ? (
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
id="weather_city"
|
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
|
||||||
type="text"
|
<MapPin className="h-3.5 w-3.5 text-accent" />
|
||||||
placeholder="e.g. Sydney, AU"
|
{settings?.weather_city}
|
||||||
value={weatherCity}
|
</span>
|
||||||
onChange={(e) => setWeatherCity(e.target.value)}
|
<button
|
||||||
onBlur={handleWeatherCitySave}
|
type="button"
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleWeatherCitySave(); }}
|
onClick={handleLocationClear}
|
||||||
className="max-w-xs"
|
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
maxLength={100}
|
title="Clear location"
|
||||||
/>
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div ref={searchRef} className="relative max-w-sm">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for a city..."
|
||||||
|
value={locationQuery}
|
||||||
|
onChange={(e) => handleLocationInputChange(e.target.value)}
|
||||||
|
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
|
||||||
|
{locationResults.map((loc, i) => {
|
||||||
|
const label = [loc.name, loc.state, loc.country].filter(Boolean).join(', ');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${loc.lat}-${loc.lon}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLocationSelect(loc)}
|
||||||
|
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span>
|
||||||
|
<span className="text-foreground font-medium">{loc.name}</span>
|
||||||
|
{(loc.state || loc.country) && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -4,10 +4,20 @@ export interface Settings {
|
|||||||
upcoming_days: number;
|
upcoming_days: number;
|
||||||
preferred_name?: string | null;
|
preferred_name?: string | null;
|
||||||
weather_city?: string | null;
|
weather_city?: string | null;
|
||||||
|
weather_lat?: number | null;
|
||||||
|
weather_lon?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeoLocation {
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
country: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Todo {
|
export interface Todo {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user