feat: add consent-based GPS location

This commit is contained in:
zv
2026-06-01 20:28:03 +02:00
parent f5898ced4f
commit ce2e1176fa
9 changed files with 256 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string | null>(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 (
<div className="mt-3 space-y-2">
{showPrompt && (
<div className="glass-subtle rounded-2xl p-3.5">
<div className="flex items-start gap-3">
<div className="rounded-full bg-sky-500/10 p-2 text-sky-700 dark:text-sky-300"><MapPinned className="size-4" /></div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
</Button>
<Button type="button" variant="ghost" onClick={dismissPrompt}><X className="size-4" />{t("location.gpsNotNow")}</Button>
</div>
</div>
</div>
</div>
)}
{!showPrompt && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<Button type="button" variant="glass" onClick={locate} disabled={isLocating || !stations.length}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button>
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-slate-600 dark:text-slate-300">{message}</p>}
</div>
)}
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">OpenStreetMap <ExternalLink className="size-3" /></a>
</p>
</div>
);
}

View File

@@ -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 && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>}
</label>
<CurrentLocationControl stations={locatedStations} />
{selectedLocation && (
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300">
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}

View File

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

View File

@@ -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<LocationSearchResult[]> {
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<LocationSearchResult[]>;
}
export async function fetchReverseLocation(latitude: number, longitude: number, language: Language): Promise<ReverseLocationResult> {
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<ReverseLocationResult>;
}

View File

@@ -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<LocationSearchResult, "name" | "province" | "latitude" | "longitude">,
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;

View File

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