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:
Kyle 2026-02-21 12:11:02 +08:00
parent 97242ee928
commit 1545da48e5
6 changed files with 237 additions and 40 deletions

View 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')

View File

@ -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())

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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;