diff --git a/AGENTS.md b/AGENTS.md index d13fb80..6fc6035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Projekt -`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżące pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznego API IMGW oraz oddzielnie oznaczoną prognozę modelową Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości. +`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżące pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznego API IMGW oraz oddzielnie oznaczoną prognozę modelową Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera. @@ -37,6 +37,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for - Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji. - Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`. - Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW. +- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. - Teksty interfejsu dodawaj równolegle po polsku i angielsku w `lib/i18n.tsx`. Nazw stacji i treści IMGW nie tłumacz automatycznie. diff --git a/README.md b/README.md index a8d16c5..34c5938 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany l Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames, natomiast wyświetlany pomiar pogody nadal pochodzi wyłącznie z najbliższej rzeczywistej stacji IMGW. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość. +Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari. + ## Stack - Next.js z App Router i TypeScript @@ -54,7 +56,9 @@ Prognoza godzinowa i 7-dniowa pochodzi z Open-Meteo Forecast API: `https://api.o Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą. -Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`. +Opcjonalny reverse geocoding dla GPS korzysta z publicznego endpointu Nominatim: `https://nominatim.openstreetmap.org/reverse`. Wywołanie następuje wyłącznie po zgodzie użytkownika. Interfejs pokazuje atrybucję OpenStreetMap. Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użycia publicznej instancji Nominatim lub zastąpić ją własną usługą. + +Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`, a reverse geocoding GPS `app/api/locations/reverse/route.ts`. ## Ograniczenia API diff --git a/app/api/locations/reverse/route.ts b/app/api/locations/reverse/route.ts new file mode 100644 index 0000000..38a445d --- /dev/null +++ b/app/api/locations/reverse/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; + +const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse"; +const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)"; + +interface RawReverseLocation { + name?: string; + display_name?: string; + address?: { + city?: string; + town?: string; + village?: string; + municipality?: string; + state?: string; + }; +} + +function parseCoordinate(value: string | null, min: number, max: number) { + if (!value?.trim()) return null; + const coordinate = Number(value); + return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? Number(coordinate.toFixed(3)) : null; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90); + const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180); + const language = searchParams.get("language") === "en" ? "en" : "pl"; + if (latitude === null || longitude === null) { + return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 }); + } + + const params = new URLSearchParams({ + format: "jsonv2", + lat: String(latitude), + lon: String(longitude), + zoom: "10", + addressdetails: "1", + "accept-language": language, + }); + + try { + const response = await fetch(`${REVERSE_GEOCODING_URL}?${params}`, { + next: { revalidate: 86400 }, + headers: { Accept: "application/json", "User-Agent": USER_AGENT }, + }); + if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 }); + const data = await response.json() as RawReverseLocation; + const name = data.address?.city + ?? data.address?.town + ?? data.address?.village + ?? data.address?.municipality + ?? data.name + ?? data.display_name?.split(",")[0]?.trim(); + if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 }); + return NextResponse.json({ + name, + province: data.address?.state ?? null, + }, { + headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" }, + }); + } catch { + return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 }); + } +} diff --git a/components/weather/current-location-control.tsx b/components/weather/current-location-control.tsx new file mode 100644 index 0000000..3cc9508 --- /dev/null +++ b/components/weather/current-location-control.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useI18n } from "@/lib/i18n"; +import { fetchReverseLocation } from "@/lib/location-api"; +import { findNearestSynopStation, type LocatedSynopStation } from "@/lib/location-utils"; +import { useWeatherStore } from "@/lib/store"; + +const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen"; + +function roundCoordinate(value: number) { + return Number(value.toFixed(3)); +} + +export function CurrentLocationControl({ stations }: { stations: LocatedSynopStation[] }) { + const { language, t } = useI18n(); + const selectLocation = useWeatherStore((state) => state.selectLocation); + const [showPrompt, setShowPrompt] = useState(false); + const [isLocating, setIsLocating] = useState(false); + const [message, setMessage] = useState(null); + const [isSecureContext, setIsSecureContext] = useState(true); + const autoLocated = useRef(false); + + const dismissPrompt = useCallback(() => { + window.localStorage.setItem(GPS_PROMPT_SEEN_KEY, "true"); + setShowPrompt(false); + }, []); + + const locate = useCallback(() => { + dismissPrompt(); + setMessage(null); + if (!window.isSecureContext) { + setMessage(t("location.gpsSecureContext")); + return; + } + if (!navigator.geolocation) { + setMessage(t("location.gpsUnavailable")); + return; + } + if (!stations.length) { + setMessage(t("location.gpsStationsPending")); + return; + } + + setIsLocating(true); + navigator.geolocation.getCurrentPosition( + async ({ coords }) => { + const latitude = roundCoordinate(coords.latitude); + const longitude = roundCoordinate(coords.longitude); + try { + const place = await fetchReverseLocation(latitude, longitude, language).catch(() => ({ + name: t("location.gpsFallbackName"), + province: null, + district: null, + })); + const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations); + if (!nearest) { + setMessage(t("location.gpsStationsPending")); + return; + } + selectLocation(nearest); + setMessage(t("location.gpsSelected", { location: nearest.name })); + } catch { + setMessage(t("location.gpsPositionUnavailable")); + } finally { + setIsLocating(false); + } + }, + (error) => { + setIsLocating(false); + setMessage(error.code === error.PERMISSION_DENIED + ? t("location.gpsDenied") + : error.code === error.TIMEOUT + ? t("location.gpsTimeout") + : t("location.gpsPositionUnavailable")); + }, + { enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 }, + ); + }, [dismissPrompt, language, selectLocation, stations, t]); + + useEffect(() => { + const animationFrame = window.requestAnimationFrame(() => { + setIsSecureContext(window.isSecureContext); + if (window.localStorage.getItem(GPS_PROMPT_SEEN_KEY) !== "true") setShowPrompt(true); + }); + return () => window.cancelAnimationFrame(animationFrame); + }, []); + + useEffect(() => { + if (!window.isSecureContext || !stations.length || autoLocated.current || !navigator.permissions?.query) return; + navigator.permissions.query({ name: "geolocation" }).then((permission) => { + if (permission.state !== "granted" || autoLocated.current) return; + autoLocated.current = true; + locate(); + }).catch(() => undefined); + }, [locate, stations.length]); + + return ( +
+ {showPrompt && ( +
+
+
+
+

{t("location.gpsPromptTitle")}

+

{t("location.gpsPromptDescription")}

+ {!isSecureContext &&

{t("location.gpsSecureContext")}

} +
+ + +
+
+
+
+ )} + {!showPrompt && ( +
+ + {message &&

{message}

} +
+ )} +

+ {t("location.gpsAttribution")} OpenStreetMap +

+
+ ); +} diff --git a/components/weather/location-search.tsx b/components/weather/location-search.tsx index 2e2737f..71e9802 100644 --- a/components/weather/location-search.tsx +++ b/components/weather/location-search.tsx @@ -7,6 +7,7 @@ import { useWeatherStore } from "@/lib/store"; import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils"; import { useI18n } from "@/lib/i18n"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; +import { CurrentLocationControl } from "@/components/weather/current-location-control"; export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) { const { language, t } = useI18n(); @@ -44,6 +45,7 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation /> {query && } + {selectedLocation && (

{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })} diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 3c0a37a..eded7f7 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -36,6 +36,21 @@ const translations = { "location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.", "location.heroSource": "stacja IMGW: {station} · około {distance} km", "location.attribution": "Wyszukiwanie miejscowości:", + "location.gpsUse": "Użyj mojej lokalizacji", + "location.gpsLocating": "Ustalam lokalizację…", + "location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?", + "location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość, najbliższą stację IMGW i prognozę. Pozycja zostanie zaokrąglona do około 100 metrów.", + "location.gpsAllow": "Użyj GPS", + "location.gpsNotNow": "Nie teraz", + "location.gpsSecureContext": "GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.", + "location.gpsUnavailable": "Ta przeglądarka nie udostępnia lokalizacji GPS.", + "location.gpsDenied": "Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.", + "location.gpsTimeout": "Nie udało się ustalić lokalizacji w wymaganym czasie. Spróbuj ponownie.", + "location.gpsPositionUnavailable": "Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.", + "location.gpsStationsPending": "Lista stacji IMGW nie jest jeszcze gotowa. Spróbuj ponownie za chwilę.", + "location.gpsFallbackName": "Bieżąca lokalizacja", + "location.gpsSelected": "Wybrano lokalizację: {location}.", + "location.gpsAttribution": "Nazwy miejsc dla GPS:", "featured.label": "Szybki wybór", "featured.title": "Wybrane stacje IMGW", "favorites.title": "Ulubione lokalizacje", @@ -165,6 +180,21 @@ const translations = { "location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.", "location.heroSource": "IMGW station: {station} · approximately {distance} km", "location.attribution": "Place search:", + "location.gpsUse": "Use my location", + "location.gpsLocating": "Finding your location…", + "location.gpsPromptTitle": "Show weather for your location?", + "location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place, the nearest IMGW station and the forecast. Your position will be rounded to approximately 100 metres.", + "location.gpsAllow": "Use GPS", + "location.gpsNotNow": "Not now", + "location.gpsSecureContext": "GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.", + "location.gpsUnavailable": "This browser does not provide GPS location access.", + "location.gpsDenied": "Location access was denied. You can change the website permission in your browser settings.", + "location.gpsTimeout": "Your location could not be determined in time. Try again.", + "location.gpsPositionUnavailable": "Your GPS location could not be determined. Check your device settings and try again.", + "location.gpsStationsPending": "The IMGW station list is not ready yet. Try again in a moment.", + "location.gpsFallbackName": "Current location", + "location.gpsSelected": "Selected location: {location}.", + "location.gpsAttribution": "GPS place names:", "featured.label": "Quick select", "featured.title": "Selected IMGW stations", "favorites.title": "Favourite locations", diff --git a/lib/location-api.ts b/lib/location-api.ts index fd0b941..ab5f71c 100644 --- a/lib/location-api.ts +++ b/lib/location-api.ts @@ -1,5 +1,5 @@ import type { Language } from "@/lib/i18n"; -import type { LocationSearchResult } from "@/types/location"; +import type { LocationSearchResult, ReverseLocationResult } from "@/types/location"; export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise { const params = new URLSearchParams({ query, language }); @@ -7,3 +7,10 @@ export async function fetchLocations(query: string, language: Language, signal?: if (!response.ok) throw new Error("Location search is temporarily unavailable."); return response.json() as Promise; } + +export async function fetchReverseLocation(latitude: number, longitude: number, language: Language): Promise { + const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), language }); + const response = await fetch(`/api/locations/reverse?${params}`); + if (!response.ok) throw new Error("Reverse location search is temporarily unavailable."); + return response.json() as Promise; +} diff --git a/lib/location-utils.ts b/lib/location-utils.ts index fe02402..45143e9 100644 --- a/lib/location-utils.ts +++ b/lib/location-utils.ts @@ -50,7 +50,10 @@ export function locateSynopStations(stations: SynopStation[], positions: MeteoSt }); } -export function findNearestSynopStation(location: LocationSearchResult, stations: LocatedSynopStation[]): SelectedLocation | null { +export function findNearestSynopStation( + location: Pick, + stations: LocatedSynopStation[], +): SelectedLocation | null { const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => { const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude); return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best; diff --git a/types/location.ts b/types/location.ts index dbf0cbc..a8bb2cb 100644 --- a/types/location.ts +++ b/types/location.ts @@ -7,6 +7,11 @@ export interface LocationSearchResult { district: string | null; } +export interface ReverseLocationResult { + name: string; + province: string | null; +} + export interface SelectedLocation { name: string; province: string | null;