Skip to content

Commit 7e01946

Browse files
luandrogithub-actions[bot]claude
authored
Exclude observations at lat/long 0,0 from all loads and exports (#149) (#152)
* fix(data): filter out observations at lat/long 0,0 from all loads Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * test(export): guard 0,0 exclusion at serialization boundary Add defense-in-depth 0,0 filtering in observationsToGeoJson and observationsToCsv so the export serializers drop null-island observations even if called with an unfiltered source list. Also guard the by-id getObservation read for consistency with the read-layer filter from #149. * refactor(coords): extract isZeroZeroCoord and cover single-zero case Extract the duplicated !(lat === 0 && lon === 0) predicate into a single isZeroZeroCoord helper in src/lib/coords.ts, replacing 4 inline copies across local-repositories.ts (repoGetObservations filter + getObservation guard) and observation-export.ts (observationsToGeoJson + observationsToCsv filters). Add regression tests asserting that real observations on the equator (lat: 0, lon: -60) or prime meridian (lat: 45, lon: 0) are KEPT — only an exact 0,0 is excluded. This guards against over-filtering that would drop legitimate single-zero-coordinate observations. * refactor(data): drop singular getObservation 0,0 guard The plural repoGetObservations filter already excludes 0,0 from all list/map/export reads, so the singular by-id guard was dead code with zero production callers. It also introduced an 'exists but hidden' contract (record in Dexie reported as not-found) with no test coverage, conflicting with the repo's TDD cycle. Reverting keeps the single source-of-truth filter and closes both pre-merge review nits. Flagged by Kimi K3 via the production 'PR Review for Merge Readiness' Langfuse prompt against PR #152. * fix(storage): exclude 0,0 observations from storage count --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5fb7c93 commit 7e01946

8 files changed

Lines changed: 323 additions & 58 deletions

src/lib/coords.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,19 @@ export function isValidCoord(lat: number, lon: number): boolean {
88
lon <= 180
99
);
1010
}
11+
12+
/**
13+
* Returns true only when BOTH lat and lon are exactly 0.
14+
*
15+
* This is used to exclude observations at the (0, 0) "null island"
16+
* coordinate, which is almost always a sentinel for "no location" rather
17+
* than a real observation. A real observation on the equator
18+
* (`lat: 0, lon: -60`) or on the prime meridian (`lat: 45, lon: 0`) is
19+
* NOT a zero-zero coordinate and must be kept.
20+
*/
21+
export function isZeroZeroCoord(
22+
lat: number | undefined,
23+
lon: number | undefined,
24+
): boolean {
25+
return lat === 0 && lon === 0;
26+
}

src/lib/local-repositories.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isZeroZeroCoord } from '@/lib/coords';
12
import { getDb } from '@/lib/db';
23
import type {
34
Alert,
@@ -167,6 +168,7 @@ export async function getObservations(
167168
.where('projectLocalId')
168169
.equals(projectLocalId)
169170
.filter((o) => !o.deleted)
171+
.filter((o) => !isZeroZeroCoord(o.lat, o.lon))
170172
.toArray();
171173
});
172174
}
@@ -176,7 +178,8 @@ export async function getObservation(
176178
): Promise<Observation | undefined> {
177179
return wrapDb(async () => {
178180
const db = getDb();
179-
return db.observations.get(localId);
181+
const observation = await db.observations.get(localId);
182+
return observation;
180183
});
181184
}
182185

src/lib/observation-export.ts

Lines changed: 57 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Feature, FeatureCollection, Point } from 'geojson';
22

3-
import { isValidCoord } from '@/lib/coords';
3+
import { isValidCoord, isZeroZeroCoord } from '@/lib/coords';
44
import type { Attachment, Field, FieldOption, Observation } from '@/lib/db';
55

66
export type ExportFormat = 'geojson' | 'csv';
@@ -47,32 +47,34 @@ export function observationsToGeoJson(
4747
observations: Observation[],
4848
context: ObservationExportContext = {},
4949
): FeatureCollection {
50-
const features: Array<Feature<Point | null>> = observations.map((obs) => {
51-
const hasValidCoords =
52-
obs.lat !== undefined &&
53-
obs.lon !== undefined &&
54-
isValidCoord(obs.lat, obs.lon);
55-
56-
const tags = buildExportTags(obs, context);
57-
58-
return {
59-
type: 'Feature' as const,
60-
geometry: hasValidCoords
61-
? { type: 'Point' as const, coordinates: [obs.lon!, obs.lat!] }
62-
: null,
63-
properties: {
64-
...tags,
65-
docId: obs.localId,
66-
remoteId: obs.remoteId,
67-
category:
68-
context.displayNamesByObservationId?.get(obs.localId) ??
69-
tags.category,
70-
presetRefDocId: obs.presetRefDocId ?? tags.presetRefDocId,
71-
createdAt: obs.createdAt,
72-
updatedAt: obs.updatedAt,
73-
},
74-
};
75-
});
50+
const features: Array<Feature<Point | null>> = observations
51+
.filter((o) => !isZeroZeroCoord(o.lat, o.lon))
52+
.map((obs) => {
53+
const hasValidCoords =
54+
obs.lat !== undefined &&
55+
obs.lon !== undefined &&
56+
isValidCoord(obs.lat, obs.lon);
57+
58+
const tags = buildExportTags(obs, context);
59+
60+
return {
61+
type: 'Feature' as const,
62+
geometry: hasValidCoords
63+
? { type: 'Point' as const, coordinates: [obs.lon!, obs.lat!] }
64+
: null,
65+
properties: {
66+
...tags,
67+
docId: obs.localId,
68+
remoteId: obs.remoteId,
69+
category:
70+
context.displayNamesByObservationId?.get(obs.localId) ??
71+
tags.category,
72+
presetRefDocId: obs.presetRefDocId ?? tags.presetRefDocId,
73+
createdAt: obs.createdAt,
74+
updatedAt: obs.updatedAt,
75+
},
76+
};
77+
});
7678

7779
return {
7880
type: 'FeatureCollection',
@@ -119,32 +121,34 @@ export function observationsToCsv(
119121
const header = CSV_COLUMNS.join(',');
120122
if (observations.length === 0) return header;
121123

122-
const rows = observations.map((obs) => {
123-
const tags = buildExportTags(obs, context);
124-
const photoUrls = tags.photoUrls ?? '';
125-
const category =
126-
context.displayNamesByObservationId?.get(obs.localId) ??
127-
tags.category ??
128-
'';
129-
130-
const hasValidCoords =
131-
obs.lat !== undefined &&
132-
obs.lon !== undefined &&
133-
isValidCoord(obs.lat, obs.lon);
134-
135-
const values: string[] = [
136-
csvEscape(obs.localId),
137-
csvEscape(category),
138-
hasValidCoords ? String(obs.lat) : '',
139-
hasValidCoords ? String(obs.lon) : '',
140-
csvEscape(obs.createdAt),
141-
csvEscape(obs.updatedAt),
142-
csvEscape(JSON.stringify(tags)),
143-
csvEscape(photoUrls),
144-
];
145-
146-
return values.join(',');
147-
});
124+
const rows = observations
125+
.filter((o) => !isZeroZeroCoord(o.lat, o.lon))
126+
.map((obs) => {
127+
const tags = buildExportTags(obs, context);
128+
const photoUrls = tags.photoUrls ?? '';
129+
const category =
130+
context.displayNamesByObservationId?.get(obs.localId) ??
131+
tags.category ??
132+
'';
133+
134+
const hasValidCoords =
135+
obs.lat !== undefined &&
136+
obs.lon !== undefined &&
137+
isValidCoord(obs.lat, obs.lon);
138+
139+
const values: string[] = [
140+
csvEscape(obs.localId),
141+
csvEscape(category),
142+
hasValidCoords ? String(obs.lat) : '',
143+
hasValidCoords ? String(obs.lon) : '',
144+
csvEscape(obs.createdAt),
145+
csvEscape(obs.updatedAt),
146+
csvEscape(JSON.stringify(tags)),
147+
csvEscape(photoUrls),
148+
];
149+
150+
return values.join(',');
151+
});
148152

149153
return [header, ...rows].join('\n');
150154
}

src/lib/storage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isZeroZeroCoord } from '@/lib/coords';
12
import { getDb } from '@/lib/db';
23

34
// ---------------------------------------------------------------------------
@@ -67,7 +68,7 @@ export async function getStorageStats(): Promise<StorageStats> {
6768
syncMetadata,
6869
] = await Promise.all([
6970
db.projects.count(),
70-
db.observations.count(),
71+
db.observations.filter((o) => !isZeroZeroCoord(o.lat, o.lon)).count(),
7172
db.alerts.count(),
7273
db.presets.count(),
7374
db.attachments.count(),

tests/unit/lib/data-layer-points.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,32 @@ describe('getProjectPoints', () => {
116116
const featureCollection = await getProjectPoints(project.localId);
117117
expect(featureCollection.features).toHaveLength(0);
118118
});
119+
120+
it('filters out observations at lat/lon 0,0', async () => {
121+
const project = await createProject({ name: 'Test' });
122+
123+
await createObservation({
124+
projectLocalId: project.localId,
125+
lat: -3.1,
126+
lon: -60.5,
127+
});
128+
await createObservation({
129+
projectLocalId: project.localId,
130+
lat: 0,
131+
lon: 0,
132+
});
133+
await createObservation({
134+
projectLocalId: project.localId,
135+
});
136+
137+
const featureCollection = await getProjectPoints(project.localId);
138+
139+
// Should only include the valid observation, not the 0,0 or the one without coords
140+
expect(featureCollection.features).toHaveLength(1);
141+
expect(featureCollection.features[0]!.geometry.coordinates).toEqual([
142+
-60.5, -3.1,
143+
]);
144+
});
119145
});
120146

121147
describe('importGeoJsonPoints', () => {

tests/unit/lib/local-repositories-fns.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,76 @@ describe('local-repositories functions — observations', () => {
259259
expect((err as DbError).code).toBe('FK_VIOLATION');
260260
}
261261
});
262+
263+
it('getObservations — filters out observations at lat/lon 0,0', async () => {
264+
const project = await createProject({ name: 'FilterZeroZero' });
265+
266+
// Create valid observation
267+
await createObservation({
268+
projectLocalId: project.localId,
269+
lat: -3.1,
270+
lon: -60.5,
271+
});
272+
273+
// Create 0,0 observation (should be filtered)
274+
await createObservation({
275+
projectLocalId: project.localId,
276+
lat: 0,
277+
lon: 0,
278+
});
279+
280+
// Create observation with no coords (should be kept)
281+
await createObservation({
282+
projectLocalId: project.localId,
283+
});
284+
285+
const observations = await getObservations(project.localId);
286+
287+
// Should only get the valid observation and the one without coords
288+
expect(observations).toHaveLength(2);
289+
290+
// Verify the 0,0 observation is not in the results
291+
const hasZeroZero = observations.some((o) => o.lat === 0 && o.lon === 0);
292+
expect(hasZeroZero).toBe(false);
293+
294+
// Verify we have the valid observation
295+
const hasValid = observations.some(
296+
(o) => o.lat === -3.1 && o.lon === -60.5,
297+
);
298+
expect(hasValid).toBe(true);
299+
300+
// Verify we have the observation without coords
301+
const hasNoCoords = observations.some(
302+
(o) => o.lat === undefined && o.lon === undefined,
303+
);
304+
expect(hasNoCoords).toBe(true);
305+
});
306+
307+
it('getObservations — keeps observations with a single zero coordinate (equator)', async () => {
308+
const project = await createProject({ name: 'SingleZero' });
309+
310+
// A real observation on the equator (lat: 0, lon: -60) — must be KEPT
311+
await createObservation({
312+
projectLocalId: project.localId,
313+
lat: 0,
314+
lon: -60,
315+
});
316+
317+
// A normal observation
318+
await createObservation({
319+
projectLocalId: project.localId,
320+
lat: -3.1,
321+
lon: -60.5,
322+
});
323+
324+
const observations = await getObservations(project.localId);
325+
326+
// Both observations are returned — only an exact 0,0 is excluded
327+
expect(observations).toHaveLength(2);
328+
329+
const hasEquator = observations.some((o) => o.lat === 0 && o.lon === -60);
330+
expect(hasEquator).toBe(true);
331+
});
262332
});
263333

264334
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)