Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/lib/coords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,19 @@ export function isValidCoord(lat: number, lon: number): boolean {
lon <= 180
);
}

/**
* Returns true only when BOTH lat and lon are exactly 0.
*
* This is used to exclude observations at the (0, 0) "null island"
* coordinate, which is almost always a sentinel for "no location" rather
* than a real observation. A real observation on the equator
* (`lat: 0, lon: -60`) or on the prime meridian (`lat: 45, lon: 0`) is
* NOT a zero-zero coordinate and must be kept.
*/
export function isZeroZeroCoord(
lat: number | undefined,
lon: number | undefined,
): boolean {
return lat === 0 && lon === 0;
}
5 changes: 4 additions & 1 deletion src/lib/local-repositories.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isZeroZeroCoord } from '@/lib/coords';
import { getDb } from '@/lib/db';
import type {
Alert,
Expand Down Expand Up @@ -167,6 +168,7 @@ export async function getObservations(
.where('projectLocalId')
.equals(projectLocalId)
.filter((o) => !o.deleted)
.filter((o) => !isZeroZeroCoord(o.lat, o.lon))
.toArray();
});
}
Expand All @@ -176,7 +178,8 @@ export async function getObservation(
): Promise<Observation | undefined> {
return wrapDb(async () => {
const db = getDb();
return db.observations.get(localId);
const observation = await db.observations.get(localId);
return observation;
});
}

Expand Down
110 changes: 57 additions & 53 deletions src/lib/observation-export.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Feature, FeatureCollection, Point } from 'geojson';

import { isValidCoord } from '@/lib/coords';
import { isValidCoord, isZeroZeroCoord } from '@/lib/coords';
import type { Attachment, Field, FieldOption, Observation } from '@/lib/db';

export type ExportFormat = 'geojson' | 'csv';
Expand Down Expand Up @@ -47,32 +47,34 @@ export function observationsToGeoJson(
observations: Observation[],
context: ObservationExportContext = {},
): FeatureCollection {
const features: Array<Feature<Point | null>> = observations.map((obs) => {
const hasValidCoords =
obs.lat !== undefined &&
obs.lon !== undefined &&
isValidCoord(obs.lat, obs.lon);

const tags = buildExportTags(obs, context);

return {
type: 'Feature' as const,
geometry: hasValidCoords
? { type: 'Point' as const, coordinates: [obs.lon!, obs.lat!] }
: null,
properties: {
...tags,
docId: obs.localId,
remoteId: obs.remoteId,
category:
context.displayNamesByObservationId?.get(obs.localId) ??
tags.category,
presetRefDocId: obs.presetRefDocId ?? tags.presetRefDocId,
createdAt: obs.createdAt,
updatedAt: obs.updatedAt,
},
};
});
const features: Array<Feature<Point | null>> = observations
.filter((o) => !isZeroZeroCoord(o.lat, o.lon))
.map((obs) => {
const hasValidCoords =
obs.lat !== undefined &&
obs.lon !== undefined &&
isValidCoord(obs.lat, obs.lon);

const tags = buildExportTags(obs, context);

return {
type: 'Feature' as const,
geometry: hasValidCoords
? { type: 'Point' as const, coordinates: [obs.lon!, obs.lat!] }
: null,
properties: {
...tags,
docId: obs.localId,
remoteId: obs.remoteId,
category:
context.displayNamesByObservationId?.get(obs.localId) ??
tags.category,
presetRefDocId: obs.presetRefDocId ?? tags.presetRefDocId,
createdAt: obs.createdAt,
updatedAt: obs.updatedAt,
},
};
});

return {
type: 'FeatureCollection',
Expand Down Expand Up @@ -119,32 +121,34 @@ export function observationsToCsv(
const header = CSV_COLUMNS.join(',');
if (observations.length === 0) return header;

const rows = observations.map((obs) => {
const tags = buildExportTags(obs, context);
const photoUrls = tags.photoUrls ?? '';
const category =
context.displayNamesByObservationId?.get(obs.localId) ??
tags.category ??
'';

const hasValidCoords =
obs.lat !== undefined &&
obs.lon !== undefined &&
isValidCoord(obs.lat, obs.lon);

const values: string[] = [
csvEscape(obs.localId),
csvEscape(category),
hasValidCoords ? String(obs.lat) : '',
hasValidCoords ? String(obs.lon) : '',
csvEscape(obs.createdAt),
csvEscape(obs.updatedAt),
csvEscape(JSON.stringify(tags)),
csvEscape(photoUrls),
];

return values.join(',');
});
const rows = observations
.filter((o) => !isZeroZeroCoord(o.lat, o.lon))
.map((obs) => {
const tags = buildExportTags(obs, context);
const photoUrls = tags.photoUrls ?? '';
const category =
context.displayNamesByObservationId?.get(obs.localId) ??
tags.category ??
'';

const hasValidCoords =
obs.lat !== undefined &&
obs.lon !== undefined &&
isValidCoord(obs.lat, obs.lon);

const values: string[] = [
csvEscape(obs.localId),
csvEscape(category),
hasValidCoords ? String(obs.lat) : '',
hasValidCoords ? String(obs.lon) : '',
csvEscape(obs.createdAt),
csvEscape(obs.updatedAt),
csvEscape(JSON.stringify(tags)),
csvEscape(photoUrls),
];

return values.join(',');
});

return [header, ...rows].join('\n');
}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isZeroZeroCoord } from '@/lib/coords';
import { getDb } from '@/lib/db';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -67,7 +68,7 @@ export async function getStorageStats(): Promise<StorageStats> {
syncMetadata,
] = await Promise.all([
db.projects.count(),
db.observations.count(),
db.observations.filter((o) => !isZeroZeroCoord(o.lat, o.lon)).count(),
db.alerts.count(),
db.presets.count(),
db.attachments.count(),
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/lib/data-layer-points.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,32 @@ describe('getProjectPoints', () => {
const featureCollection = await getProjectPoints(project.localId);
expect(featureCollection.features).toHaveLength(0);
});

it('filters out observations at lat/lon 0,0', async () => {
const project = await createProject({ name: 'Test' });

await createObservation({
projectLocalId: project.localId,
lat: -3.1,
lon: -60.5,
});
await createObservation({
projectLocalId: project.localId,
lat: 0,
lon: 0,
});
await createObservation({
projectLocalId: project.localId,
});

const featureCollection = await getProjectPoints(project.localId);

// Should only include the valid observation, not the 0,0 or the one without coords
expect(featureCollection.features).toHaveLength(1);
expect(featureCollection.features[0]!.geometry.coordinates).toEqual([
-60.5, -3.1,
]);
});
});

describe('importGeoJsonPoints', () => {
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/lib/local-repositories-fns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,76 @@ describe('local-repositories functions — observations', () => {
expect((err as DbError).code).toBe('FK_VIOLATION');
}
});

it('getObservations — filters out observations at lat/lon 0,0', async () => {
const project = await createProject({ name: 'FilterZeroZero' });

// Create valid observation
await createObservation({
projectLocalId: project.localId,
lat: -3.1,
lon: -60.5,
});

// Create 0,0 observation (should be filtered)
await createObservation({
projectLocalId: project.localId,
lat: 0,
lon: 0,
});

// Create observation with no coords (should be kept)
await createObservation({
projectLocalId: project.localId,
});

const observations = await getObservations(project.localId);

// Should only get the valid observation and the one without coords
expect(observations).toHaveLength(2);

// Verify the 0,0 observation is not in the results
const hasZeroZero = observations.some((o) => o.lat === 0 && o.lon === 0);
expect(hasZeroZero).toBe(false);

// Verify we have the valid observation
const hasValid = observations.some(
(o) => o.lat === -3.1 && o.lon === -60.5,
);
expect(hasValid).toBe(true);

// Verify we have the observation without coords
const hasNoCoords = observations.some(
(o) => o.lat === undefined && o.lon === undefined,
);
expect(hasNoCoords).toBe(true);
});

it('getObservations — keeps observations with a single zero coordinate (equator)', async () => {
const project = await createProject({ name: 'SingleZero' });

// A real observation on the equator (lat: 0, lon: -60) — must be KEPT
await createObservation({
projectLocalId: project.localId,
lat: 0,
lon: -60,
});

// A normal observation
await createObservation({
projectLocalId: project.localId,
lat: -3.1,
lon: -60.5,
});

const observations = await getObservations(project.localId);

// Both observations are returned — only an exact 0,0 is excluded
expect(observations).toHaveLength(2);

const hasEquator = observations.some((o) => o.lat === 0 && o.lon === -60);
expect(hasEquator).toBe(true);
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading