teqo-backup/src/utilities/campaignGeolocation.ts
Francisco Ignácio Fontoura Solla e482005ce0 B14: municipio mais proximo (atalho geo no Inicio staff)
Card "Onde estou" no dashboard staff: point-in-polygon contra a malha
municipal que o painel do mapa ja carrega, sem artefato de centroides,
sem migration, sem collection, sem server action e sem Consent -- a
posicao e lida por navigator.geolocation e casada no browser, nunca sai
do aparelho.

- src/lib/municipalityProximity.ts: haversine, ray casting, centroide
  ponderado por area e uniao discriminada
  (inScope | zoneCity | outOfScope | outsideBahia)
- src/utilities/campaignGeolocation.ts: flag de sessao, leitura guardada
  de permissao e requestCurrentPosition com falha tipada
- Salvador nao ganha ZE chutada: link para a lista filtrada, com href
  serializado no servidor (importar o serializador canonico no cliente
  custava 21 kB de First Load JS)
- fallback fora da carteira ordenado por centroide, teto de 150 km
- fix grosseiro (>10 km) mostra o resultado com ressalva de precisao

/campanha First Load JS: 265 -> 268 kB.
2026-07-26 17:33:33 -03:00

121 lines
4.1 KiB
TypeScript

/**
* B14 — browser geolocation for the `/campanha` field desk.
*
* The position never leaves the device: it is read here and matched against the
* committed municipality mesh in the client, so there is no server action, no
* PII at rest and no `Consent` key (same boundary as `recentVisits`).
*
* Sibling of `recentVisits.ts` on purpose — both are browser-storage/browser-API
* clients, not Payload-coupled loaders.
*/
export const GEO_PROMPT_SESSION_KEY = 'teqo:campaign:geo-prompted-session'
/** Field networks are slow; past this the card offers the manual retry instead of spinning. */
const POSITION_TIMEOUT_MS = 10_000
/** A fix from the last few minutes still answers "which município am I in". */
const POSITION_MAX_AGE_MS = 5 * 60_000
/**
* Above this reported radius the fix is a network/IP guess, not a device fix: on a
* desk it can land tens of kilometres away, which is enough to name the wrong
* município with total confidence. Surfaced as a caveat, never silently trusted.
*/
export const COARSE_ACCURACY_M = 10_000
export type GeolocationPermissionState = 'granted' | 'denied' | 'prompt' | 'unknown'
type GeolocationFix = {
lat: number
lng: number
/** Reported accuracy radius in metres — surfaced only to withhold false precision. */
accuracyM: number
}
export type GeolocationFailureReason = 'unsupported' | 'denied' | 'unavailable' | 'timeout'
export type GeolocationResult =
| { ok: true; fix: GeolocationFix }
| { ok: false; reason: GeolocationFailureReason }
/**
* Whether this tab already fired the automatic prompt. `sessionStorage` (not
* `localStorage`) on purpose: a new session may legitimately ask again after the
* user changes the permission in the OS, but one navigation inside the dashboard
* must never re-prompt.
*/
export const hasPromptedThisSession = (): boolean => {
if (typeof window === 'undefined') return false
try {
return sessionStorage.getItem(GEO_PROMPT_SESSION_KEY) === '1'
} catch {
// Private mode: treat as already prompted so the automatic path stays silent.
return true
}
}
export const markPromptedThisSession = (): void => {
if (typeof window === 'undefined') return
try {
sessionStorage.setItem(GEO_PROMPT_SESSION_KEY, '1')
} catch {
// Ignore quota / private-mode failures.
}
}
/**
* Reads the permission without triggering a dialog. Safari shipped
* `navigator.permissions` without the `geolocation` descriptor for years, so
* `unknown` is a normal answer, not an error.
*/
export const readGeolocationPermissionState = async (): Promise<GeolocationPermissionState> => {
if (typeof window === 'undefined' || !('geolocation' in navigator)) return 'unknown'
if (!navigator.permissions?.query) return 'unknown'
try {
const status = await navigator.permissions.query({ name: 'geolocation' })
return status.state
} catch {
return 'unknown'
}
}
const failureReasonFor = (error: GeolocationPositionError): GeolocationFailureReason => {
if (error.code === error.PERMISSION_DENIED) return 'denied'
if (error.code === error.TIMEOUT) return 'timeout'
return 'unavailable'
}
/**
* One position request, resolving to a typed result instead of throwing — every
* failure mode is a rendered state in the card.
*
* High accuracy stays off: the answer is a município, and a GPS lock costs
* seconds and battery for precision that changes nothing here.
*/
export const requestCurrentPosition = (): Promise<GeolocationResult> => {
if (typeof window === 'undefined' || !navigator.geolocation?.getCurrentPosition) {
return Promise.resolve({ ok: false, reason: 'unsupported' })
}
return new Promise<GeolocationResult>((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) =>
resolve({
ok: true,
fix: {
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracyM: position.coords.accuracy,
},
}),
(error) => resolve({ ok: false, reason: failureReasonFor(error) }),
{
enableHighAccuracy: false,
timeout: POSITION_TIMEOUT_MS,
maximumAge: POSITION_MAX_AGE_MS,
},
)
})
}