Compare commits

...

8 Commits

Author SHA1 Message Date
elpatron 6c48085904 chore: release v0.1.0.109 2026-06-03 12:50:09 +02:00
elpatron 07de51be22 chore: release v0.1.0.108 2026-06-03 12:42:26 +02:00
elpatron d654aad937 Allow crew to read AI summaries without losing them on save.
Preserve existing summaries when crew edits entries and show a read-only hint in the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 12:42:17 +02:00
elpatron dd111ce01f chore: release v0.1.0.107 2026-06-03 11:50:37 +02:00
elpatron 978e132c70 Remove unused decryptJson import after crew decrypt fix.
Fixes client TypeScript build blocked by pre-deploy checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:50:01 +02:00
elpatron 1ecebc5dbb chore: release v0.1.0.106 2026-06-03 11:49:23 +02:00
elpatron caf85ad9eb Fix shared logbook access for crew after AI summary sync.
Correct owner detection while the logbook loads, preserve AI summaries on
live-log saves, skip corrupt entry decrypts, and never regenerate keys for
shared logbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:48:45 +02:00
elpatron d637fbea16 Show clear offline messages for OWM weather and AI summaries.
Users see localized feedback when OpenWeatherMap or travel-day summary
features are used without connectivity, instead of generic API errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:43:10 +02:00
15 changed files with 141 additions and 17 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.106
0.1.0.110
+3 -2
View File
@@ -103,7 +103,7 @@ function App() {
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
useEffect(() => {
if (!activeLogbookId) {
@@ -574,7 +574,8 @@ function App() {
const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
activeAccessRole === 'OWNER' ||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
if (showUserProfile) {
return (
+20
View File
@@ -152,6 +152,7 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('')
@@ -269,6 +270,17 @@ export default function LiveLogView({
}
}, [logbookId, applyLoadedEntry, t])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
void runInit()
return () => {
@@ -503,6 +515,10 @@ export default function LiveLogView({
const handleFetchOwmWeather = () => {
if (!entryId || busy || weatherOwmLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
const position = getLastPositionFixWithin(
events,
@@ -533,6 +549,10 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
+4 -4
View File
@@ -3,13 +3,13 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson, encryptJson } from '../services/crypto.js'
import { encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import { findTodayEntryId, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -136,7 +136,7 @@ export default function LogEntriesList({
}
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
@@ -266,7 +266,7 @@ export default function LogEntriesList({
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
+51 -5
View File
@@ -43,6 +43,7 @@ import {
generateTravelDaySummary,
TravelDaySummaryApiError
} from '../services/aiSummary.js'
import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -457,13 +458,28 @@ export default function LogEntryEditor({
const eventsOverride = normalized.eventsOverride
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
const summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
const summaryAtToSave =
let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
let summaryAtToSave =
normalized.aiSummaryGeneratedAt !== undefined ? normalized.aiSummaryGeneratedAt : aiSummaryGeneratedAt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Crew edits must not drop the skipper's AI summary when it is not loaded into editor state.
if (!summaryToSave.trim()) {
const local = await db.entries.get(entryId)
if (local) {
const decrypted = await tryDecryptEntryPayload(local, masterKey)
const existing =
decrypted && typeof decrypted.aiSummary === 'string' ? decrypted.aiSummary.trim() : ''
if (existing) {
summaryToSave = existing
summaryAtToSave =
typeof decrypted.aiSummaryGeneratedAt === 'string' ? decrypted.aiSummaryGeneratedAt : ''
}
}
}
const entryData: Record<string, unknown> = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(skipperToSave),
@@ -527,6 +543,8 @@ export default function LogEntryEditor({
}, [])
useEffect(() => {
setCanSignSkipper(false)
setCanSignCrew(false)
getLogbookAccess(logbookId).then((access) => {
if (!access) return
setCanSignSkipper(access.isOwner)
@@ -538,7 +556,7 @@ export default function LogEntryEditor({
}, [logbookId])
useEffect(() => {
if (!canSignSkipper || readOnly) {
if (!canSignSkipper || readOnly || !isOnline) {
setAiSummaryRemaining(null)
return
}
@@ -553,7 +571,7 @@ export default function LogEntryEditor({
console.warn('Failed to load AI summary usage:', err)
})
return () => { cancelled = true }
}, [canSignSkipper, readOnly, logbookId, entryId])
}, [canSignSkipper, readOnly, isOnline, logbookId, entryId])
useEffect(() => {
const seq = ++entryHashSeqRef.current
@@ -986,6 +1004,10 @@ export default function LogEntryEditor({
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
return
}
if (!isOnline) {
showAlert(t('logs.weather_offline'))
return
}
try {
const data = await fetchOpenWeatherCurrent(
@@ -999,6 +1021,10 @@ export default function LogEntryEditor({
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
@@ -1025,6 +1051,11 @@ export default function LogEntryEditor({
}
const handleFetchWeather = async () => {
if (!isOnline) {
showAlert(t('logs.weather_offline'))
return
}
const localToday = new Date()
const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}`
@@ -1066,6 +1097,10 @@ export default function LogEntryEditor({
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
@@ -1079,6 +1114,10 @@ export default function LogEntryEditor({
const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (!isOnline) {
setAiSummaryError(t('logs.ai_summary_offline'))
return
}
if (aiSummaryRemaining === 0) {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
return
@@ -1135,7 +1174,9 @@ export default function LogEntryEditor({
})
} catch (err) {
if (err instanceof TravelDaySummaryApiError) {
if (err.code === 'NO_KEY') {
if (err.code === 'OFFLINE') {
setAiSummaryError(t('logs.ai_summary_offline'))
} else if (err.code === 'NO_KEY') {
setAiSummaryError(t('logs.ai_summary_error_no_key'))
} else if (err.code === 'RATE_LIMITED') {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
@@ -1526,6 +1567,11 @@ export default function LogEntryEditor({
<Sparkles size={20} className="form-icon" />
<h3>{t('logs.ai_summary_title')}</h3>
</div>
{aiSummary.trim() && !canSignSkipper && (
<p style={{ margin: '0 0 12px', fontSize: '0.9rem', opacity: 0.8 }}>
{t('logs.ai_summary_read_only')}
</p>
)}
{aiSummary.trim() ? (
<p style={{ whiteSpace: 'pre-wrap', margin: '0 0 16px', lineHeight: 1.5 }}>{aiSummary}</p>
) : (
+3
View File
@@ -358,6 +358,7 @@
"event_remarks": "Bemærkninger / hændelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Kald vejret op",
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
"event_wind_pressure": "Lufttryk (hPa)",
"event_heel": "Krængning (°)",
"event_sails": "Sejlhåndtering/motor",
@@ -372,6 +373,7 @@
"export_pdf": "Download PDF.",
"exporting_pdf": "PDF er genereret...",
"ai_summary_title": "AI-resumé",
"ai_summary_read_only": "Oprettet af skipperen — kun læsning for besætningen.",
"ai_summary_empty": "Intet resumé endnu.",
"ai_summary_generate": "Generér resumé",
"ai_summary_regenerate": "Generér igen",
@@ -381,6 +383,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
+3
View File
@@ -358,6 +358,7 @@
"event_remarks": "Bemerkungen / Vorkommnisse",
"gps_btn": "GPS-Koordinaten abrufen",
"weather_btn": "OpenWeatherMap Wetter abrufen",
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)",
"event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor",
@@ -372,6 +373,7 @@
"export_pdf": "PDF herunterladen",
"exporting_pdf": "PDF wird generiert...",
"ai_summary_title": "KI-Zusammenfassung",
"ai_summary_read_only": "Vom Skipper erstellt — nur lesbar für die Crew.",
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
"ai_summary_generate": "Zusammenfassung generieren",
"ai_summary_regenerate": "Neu generieren",
@@ -381,6 +383,7 @@
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
+3
View File
@@ -358,6 +358,7 @@
"event_remarks": "Remarks / Events",
"gps_btn": "Get GPS Location",
"weather_btn": "Fetch OpenWeatherMap Weather",
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)",
"event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status",
@@ -372,6 +373,7 @@
"export_pdf": "Download PDF",
"exporting_pdf": "Generating PDF...",
"ai_summary_title": "AI Summary",
"ai_summary_read_only": "Created by the skipper — read-only for crew.",
"ai_summary_empty": "No summary yet.",
"ai_summary_generate": "Generate summary",
"ai_summary_regenerate": "Regenerate",
@@ -381,6 +383,7 @@
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments (E2E Encrypted)",
"photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
+3
View File
@@ -358,6 +358,7 @@
"event_remarks": "Merknader / hendelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring opp været",
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
"event_wind_pressure": "Lufttrykk (hPa)",
"event_heel": "Helning (°)",
"event_sails": "Seilhåndtering / motor",
@@ -372,6 +373,7 @@
"export_pdf": "Last ned PDF",
"exporting_pdf": "PDF genereres...",
"ai_summary_title": "AI-sammendrag",
"ai_summary_read_only": "Opprettet av skipperen — kun lesbar for mannskapet.",
"ai_summary_empty": "Ingen sammendrag ennå.",
"ai_summary_generate": "Generer sammendrag",
"ai_summary_regenerate": "Generer på nytt",
@@ -381,6 +383,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
"photos_title": "Bildevedlegg (E2E-kryptert)",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
+3
View File
@@ -358,6 +358,7 @@
"event_remarks": "Anmärkningar / incidenter",
"gps_btn": "Hämta GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring upp väder",
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
"event_wind_pressure": "Lufttryck (hPa)",
"event_heel": "Krängning (°)",
"event_sails": "Segelhantering / motor",
@@ -372,6 +373,7 @@
"export_pdf": "Hämta PDF.",
"exporting_pdf": "PDF genereras...",
"ai_summary_title": "AI-sammanfattning",
"ai_summary_read_only": "Skapad av skepparen — endast läsning för besättningen.",
"ai_summary_empty": "Ingen sammanfattning ännu.",
"ai_summary_generate": "Generera sammanfattning",
"ai_summary_regenerate": "Generera igen",
@@ -381,6 +383,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
"photos_title": "Fotobilagor (E2E-krypterade)",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
+6 -2
View File
@@ -5,11 +5,11 @@ import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayl
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
export class TravelDaySummaryApiError extends Error {
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(
message: string,
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'TravelDaySummaryApiError'
@@ -146,6 +146,10 @@ export async function generateTravelDaySummary(params: {
language: string
context: TravelDaySummaryContext
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
if (!navigator.onLine) {
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
}
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
+12
View File
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const isShared = localLb?.isShared === 1
const masterKey = getActiveMasterKey()
let key = await getLogbookKey(logbookId)
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// Key works, return it
return key
} catch (err) {
if (isShared) {
throw new Error(
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
)
}
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
try {
const parsed = JSON.parse(encryptedTitle)
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// If no logbook key exists yet
if (!key) {
if (isShared) {
throw new Error(
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
)
}
if (encryptedTitle && masterKey) {
try {
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
+12 -1
View File
@@ -124,11 +124,22 @@ function buildEncryptedPayload(
})
const clear = options.clearSignatures
return {
const entryData: Record<string, unknown> = {
...payload,
signSkipper: clear ? '' : (data.signSkipper ?? ''),
signCrew: clear ? '' : (data.signCrew ?? '')
}
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
if (summary) {
entryData.aiSummary = summary
entryData.aiSummaryGeneratedAt =
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
? data.aiSummaryGeneratedAt
: new Date().toISOString()
}
return entryData
}
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
+11
View File
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
})
})
it('throws OFFLINE when navigator.onLine is false', async () => {
vi.stubGlobal('navigator', { ...navigator, onLine: false })
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
expect(apiFetch).not.toHaveBeenCalled()
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
+6 -2
View File
@@ -7,9 +7,9 @@ import {
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'WeatherApiError'
this.code = code
@@ -26,6 +26,10 @@ export async function fetchOpenWeatherCurrent(
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new WeatherApiError('Offline', 'OFFLINE')
}
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {