fix(privacy): store environment coordinates at 1 decimal instead of 2

The home location of the environment module was rounded to 2 decimals,
about 1 km, which places a home in a neighbourhood rather than the town the
schema comment promised. The weather archive it feeds is gridded at 9 to
25 km, so the extra precision bought nothing.

Every stored coordinate of the module now goes through the same 1-decimal
rounding (about 11 km north-south, 7 km east-west at 50° N): the home, the
travel periods, the geocoder's results, the per-day weather rows, and a
restore of a file written with finer values. Migration 0361 rounds the
existing users, travel periods and per-day rows; running it twice changes
nothing more. The answers keep their shape, plain numbers. The API
descriptions and the schema comment say 1 decimal now.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 10:37:37 +02:00
1 parent b8e78a78b3
commit f2e32afb24
12 files changed
+347 -46

No files matched your search

+2 -2
View File
@@ -2,8 +2,8 @@
* `PUT /api/environment/home` — set the account's coarse home location.
*
* The picked city (rounded lat/lon + label + IANA timezone) is stored on the
* user. Coordinates are rounded to ~city granularity here as a defence-in-depth
* floor even if a client sends finer values. Setting a home enqueues a
* user. Coordinates are rounded to 1 decimal (about 11 km) here as a
* defence-in-depth floor even if a client sends finer values. Setting a home enqueues a
* lookback fetch so weather appears without waiting for the nightly tick.
* Module-gated; `userId` is narrowed from auth.
*/
+1 -1
View File
@@ -3,7 +3,7 @@
*
* For any day in [startDate, endDate] the weather fetch resolves against this
* coarse location instead of home (a declared trip dominates the home
* fallback). Coordinates are rounded to ~city granularity. Adding an override
* fallback). Coordinates are rounded to 1 decimal (about 11 km). Adding an override
* enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is
* narrowed from auth.
*/
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* v1.39.4 — the environment module stores coordinates at 1 decimal (about
* 11 km), not 2 (about 1 km). The geocoder is where a picked city's
* coordinates enter, so its results must already be coarse.
*/
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("coarse location", () => {
it("rounds a coordinate to one decimal", async () => {
const { roundCoarse } = await import("../open-meteo");
expect(roundCoarse(52.5163)).toBe(52.5);
expect(roundCoarse(13.3777)).toBe(13.4);
expect(roundCoarse(-33.8688)).toBe(-33.9);
// Already coarse values pass through unchanged.
expect(roundCoarse(48.1)).toBe(48.1);
});
it("hands back geocoding results at one decimal", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json(
{
results: [
{
name: "Berlin",
latitude: 52.52437,
longitude: 13.41053,
country: "Germany",
timezone: "Europe/Berlin",
},
],
},
{ status: 200 },
),
),
);
const { geocodeLocation } = await import("../open-meteo");
const [hit] = await geocodeLocation("Berlin");
expect(hit).toMatchObject({ lat: 52.5, lon: 13.4 });
});
});
+10 -3
View File
@@ -40,7 +40,7 @@ export const OPEN_METEO_ATTRIBUTION = "Weather data by Open-Meteo.com";
/** One geocoding match — coarse city placement for the home-location picker. */
export interface GeocodeResult {
/** Rounded coarse latitude (2 dp — ~1 km, the privacy floor). */
/** Rounded coarse latitude (1 dp, about 11 km, the privacy floor). */
lat: number;
/** Rounded coarse longitude (2 dp). */
lon: number;
@@ -68,9 +68,16 @@ export interface DailyEnvironmentObservation {
weatherCode: number | null;
}
/** Round a coordinate to 2 decimals (~1 km) — the coarse-location privacy floor. */
/**
* Round a coordinate to 1 decimal, the coarse-location privacy floor: about
* 11 km north-south and 7 km east-west at 50° N, the size of a town or a
* city district rather than a street. The weather reanalysis behind the
* archive is gridded at 9 to 25 km, so a finer value buys no better weather.
* Every stored coordinate of this module goes through here (v1.39.4; it was
* 2 decimals, about 1 km, before).
*/
export function roundCoarse(value: number): number {
return Math.round(value * 100) / 100;
return Math.round(value * 10) / 10;
}
function firstNumber(value: unknown): number | null {
+5 -2
View File
@@ -24,6 +24,7 @@
import { prisma } from "@/lib/db";
import {
fetchDailyEnvironment,
roundCoarse,
type DailyEnvironmentObservation,
} from "@/lib/environment/open-meteo";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
@@ -256,8 +257,10 @@ function buildUpsert(
obs: DailyEnvironmentObservation,
) {
const data = {
lat: loc.lat,
lon: loc.lon,
// Already coarse at the source; rounded again so a per-day row never holds
// more precision than the privacy floor, whatever resolved it.
lat: roundCoarse(loc.lat),
lon: roundCoarse(loc.lon),
locationLabel: loc.label,
source: loc.source,
tempMin: obs.tempMin,
+7 -4
View File
@@ -60,6 +60,7 @@
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { roundCoarse } from "@/lib/environment/open-meteo";
/** One day's environmental observation, at the location resolved for that day. */
export interface EnvironmentContextBackupEntry {
@@ -312,8 +313,10 @@ export async function restoreEnvironmentData(
// neither bound goes near a `Date`.
startDate: entry.startDate,
endDate: entry.endDate,
lat: entry.lat,
lon: entry.lon,
// A file written before v1.39.4 carries 2-decimal coordinates; they
// come back at today's privacy floor.
lat: roundCoarse(entry.lat),
lon: roundCoarse(entry.lon),
label: entry.label,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
@@ -326,8 +329,8 @@ export async function restoreEnvironmentData(
data: payload.environmentContexts.map((entry) => ({
userId: ownerId,
date: entry.date,
lat: entry.lat,
lon: entry.lon,
lat: roundCoarse(entry.lat),
lon: roundCoarse(entry.lon),
locationLabel: entry.locationLabel,
source: entry.source,
tempMin: entry.tempMin ?? null,
+9 -7
View File
@@ -11,7 +11,7 @@
* the `environment` module off, every one of the six paths answers 403
* `module.disabled` before it does anything else.
*
* Coordinates are rounded to ~city granularity (2 dp) on the way in, as a
* Coordinates are rounded to 1 decimal (about 11 km) on the way in, as a
* floor under whatever precision a client sends. That rounding is why the
* geocode results and the stored home read back coarser than they were
* picked.
@@ -58,8 +58,10 @@ const geocodeQuery = geocodeQuerySchema.meta({
const environmentHome = z
.object({
lat: z.number().describe("Coarse latitude, rounded to 2 dp (~1 km)."),
lon: z.number().describe("Coarse longitude, rounded to 2 dp."),
lat: z
.number()
.describe("Coarse latitude, rounded to 1 decimal (about 11 km)."),
lon: z.number().describe("Coarse longitude, rounded to 1 decimal."),
label: z.string().nullable(),
timezone: z
.string()
@@ -142,8 +144,8 @@ const environmentOverview = z
const geocodeResult = z
.object({
lat: z.number().describe("Coarse latitude, rounded to 2 dp."),
lon: z.number().describe("Coarse longitude, rounded to 2 dp."),
lat: z.number().describe("Coarse latitude, rounded to 1 decimal."),
lon: z.number().describe("Coarse longitude, rounded to 1 decimal."),
label: z
.string()
.describe(
@@ -186,7 +188,7 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Environment"],
summary: "Set the coarse home location",
description:
"Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 2 dp server-side regardless of what was sent.\n\nRe-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover.\n\nOn success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way.\n\nBody cap 4 KiB. Module-gated.",
"Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 1 decimal (about 11 km) server-side regardless of what was sent.\n\nRe-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover.\n\nOn success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way.\n\nBody cap 4 KiB. Module-gated.",
requestBody: {
required: true,
content: { "application/json": { schema: homeLocationRequest } },
@@ -309,7 +311,7 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Environment"],
summary: "Add a travel override",
description:
"Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 2 dp server-side.\n\nAdding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them.\n\nBody cap 4 KiB. Module-gated.",
"Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 1 decimal (about 11 km) server-side.\n\nAdding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them.\n\nBody cap 4 KiB. Module-gated.",
requestBody: {
required: true,
content: { "application/json": { schema: travelLocationRequest } },