Skip to content

feat: add device location height - #1068

Open
jona159 wants to merge 59 commits into
devfrom
feat/device-location-height
Open

feat: add device location height#1068
jona159 wants to merge 59 commits into
devfrom
feat/device-location-height

Conversation

@jona159

@jona159 jona159 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Type of Change

  • Dependency upgrade
  • Bug fix (non-breaking change)
  • Breaking change
    • e.g. a fixed bug or new feature that may break something else
  • New feature
  • Code quality improvements
    • e.g. refactoring, documentation, tests, tooling, ...

Implementation

Checklist

  • I gave this pull request a meaningful title
  • My pull request is targeting the dev branch
  • I have added documentation to my code
  • I have deleted code that I have commented out

Additional Information

  • This PR closes #

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review Change Stack

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 77c3b045-a9cd-4e4d-bfc2-c166b6e68a0b

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Summary

Summary by CodeRabbit

  • New Features

    • Added optional height-above-ground entry during device creation and editing.
    • Terrain elevation is retrieved automatically to calculate and display height above sea level.
    • GeoJSON and API responses now support optional three-dimensional coordinates.
    • Added elevation status, retry, attribution, and loading feedback.
  • Bug Fixes

    • Navigation validates and saves location data before changing steps.
    • Location updates preserve height information consistently.
    • Improved handling of elevation lookup and device creation errors.

Walkthrough

The change adds terrain elevation lookup and optional above-ground height handling across device forms, routes, persistence, API schemas, GeoJSON output, configuration, localization, and tests.

Changes

Device elevation support

Layer / File(s) Summary
Elevation service and location contracts
app/services/elevation-service.server.ts, app/lib/elevation.ts, app/lib/location.ts, app/lib/openapi/schemas/location.ts, app/lib/env.server.ts
The elevation service validates coordinates, queries OpenTopoData, caches results, handles failures, and calculates sea-level height from terrain elevation and above-ground height.
Creation and editing elevation UI
app/components/device/new/*, app/routes/device.$deviceId.edit.location.tsx, app/hooks/use-terrain-elevation.ts, public/locales/*
The forms collect above-ground height, request terrain elevation, calculate final height, handle loading and retry states, and display localized elevation information.
Device routes and resource API
app/routes/device.new.tsx, app/routes/api.boxes.ts, app/routes/api.boxes.$deviceId.ts, app/routes/resources.elevation.ts
Creation and update routes resolve above-ground height before persistence. The elevation resource validates authenticated requests and maps lookup failures to structured responses.
Device storage and API serialization
app/db/drizzle/*, app/db/schema/device.ts, app/db/models/*, app/lib/device-transform.ts, app/lib/openapi/schemas/*, app/services/device-service.server.ts, app/lib/api-schemas/devices.ts
Device height is stored as a nullable double-precision value, included in projections and sensor metadata, and emitted as an optional third GeoJSON coordinate.
New-device validation and navigation
app/lib/new-device-form.ts, app/components/device/new/new-device-stepper.tsx, app/components/device/new/sensors-info.tsx, app/lib/device-general.ts
Shared schemas validate the complete new-device submission. Step navigation validates and persists current-step data before moving.
Elevation and height behavior tests
tests/lib/*, tests/routes/*
Tests cover parsing, validation, persistence, zero and negative values, nullable values, and two- or three-dimensional API coordinates.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟠 High · up to a65e6

This change adds device-height handling across creation, editing, and API flows, but unresolved issues can silently alter or erase stored heights, persist invalid coordinates, make device creation fail or create duplicates after retries, and delay writes when elevation requests accumulate. The PR is not ready to merge until these correctness and availability risks are fixed or explicitly accepted.

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant LocationForm
  participant DeviceRoute
  participant ElevationService
  participant DeviceModel
  participant Database
  Operator->>LocationForm: enter coordinates and heightAboveGround
  LocationForm->>ElevationService: request terrain elevation
  ElevationService-->>LocationForm: return terrain elevation and metadata
  LocationForm->>DeviceRoute: submit validated location
  DeviceRoute->>ElevationService: calculate height above sea level
  DeviceRoute->>DeviceModel: persist calculated height
  DeviceModel->>Database: save device height
  DeviceModel-->>DeviceRoute: return device data
  DeviceRoute-->>Operator: display 2D or 3D GeoJSON location
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description contains only an uncompleted template and provides no meaningful implementation or change summary. Complete the description with the change type, implementation details, rationale, testing information, and related issue.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding device location height functionality.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/device-location-height

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 65.05% 2331 / 3583
🔵 Statements 63.53% 2408 / 3790
🔵 Functions 61.78% 443 / 717
🔵 Branches 50.68% 1144 / 2257
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
app/db/models/device-height.ts 100% 100% 100% 100%
app/db/models/device.server.ts 63% 68.01% 59.61% 64.04% 96-101, 109, 216, 228-229, 295-334, 370, 431, 465, 500, 525, 539, 569, 573, 581-583, 589-591, 609-611, 615-617, 664-666, 681, 702-901, 952-956, 984-990, 995-1011, 1098-1100, 1111, 1118-1119, 1131-1138, 1142-1153, 1199, 1307-1327, 1346
app/db/models/elevation-consent.server.ts 5.88% 0% 0% 6.25% 9-76
app/db/models/sensor.server.ts 59.18% 58.06% 47.05% 60.41% 71-73, 129-134, 159-234, 289
app/db/schema/device.ts 66.66% 100% 50% 63.63% 61-66, 81-87
app/db/schema/elevation-consent.ts 50% 100% 33.33% 50% 12-15
app/db/schema/index.ts 100% 100% 100% 100%
app/lib/device-transform.ts 96.15% 93.1% 100% 100% 165
app/lib/elevation.ts 100% 100% 100% 100%
app/lib/location.ts 41.17% 25.58% 33.33% 42.55% 54-75, 144, 163-283
app/lib/api-schemas/devices.ts 63.41% 50% 68.42% 62.16% 110-111, 121, 130-133, 144-161, 169, 189-194
app/lib/openapi/schemas/claim.ts 100% 100% 100% 100%
app/lib/openapi/schemas/consent.ts 100% 100% 100% 100%
app/lib/openapi/schemas/device.ts 100% 83.33% 100% 100%
app/lib/openapi/schemas/location.ts 87.5% 50% 100% 85.71% 92-96, 121-125
app/lib/openapi/schemas/measurement.ts 100% 100% 100% 100%
app/lib/openapi/schemas/user.ts 100% 100% 100% 100%
app/routes/api.boxes.$deviceId.$sensorId.ts 75% 52.94% 100% 75% 155-157, 165, 181-183, 199-200, 208-209, 257-258, 262-263, 271-278
app/routes/api.boxes.$deviceId.ts 69.76% 60.36% 81.81% 71.2% 115-119, 288, 299, 313, 322-328, 334, 337, 350, 360, 366-373, 381, 385, 389-391, 398-431, 519, 523, 529, 537, 547-552, 560, 564, 579
app/routes/api.boxes.ts 96.29% 92.85% 100% 96.29% 221, 295
app/services/device-service.server.ts 28.33% 20.68% 16.66% 29.09% 59, 70-75, 81-189, 207, 224, 246-249
app/services/elevation-service.server.ts 13.33% 0% 0% 14.43% 51-275
Generated in workflow #2980 for commit bc62cff by the Vitest Coverage Report Action

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/db/models/device.server.ts (1)

429-469: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

updateDevice clears a stored height when the caller omits it.

Line 469 writes height ?? null whenever args.location is present. UpdateDeviceArgs.location declares height?: number, so a partial update such as { location: { lat, lng } } silently overwrites an existing height with null.

This diverges from the convention used at Lines 406-417, where the function only writes a column when the argument is not undefined. Latitude and longitude are required inside location, but height is not.

Write height only when the caller supplies it.

🛠️ Proposed fix to preserve an existing height
 			setColumns['latitude'] = lat
 			setColumns['longitude'] = lng
-			setColumns['height'] = height ?? null
+			if (height !== undefined) {
+				setColumns['height'] = height
+			}

If clearing the height must remain possible, widen the type to height?: number | null and keep the undefined guard.

🧹 Nitpick comments (2)
app/db/models/device.server.ts (1)

679-682: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the coordinate-building logic into one helper.

The same conditional now appears at Lines 679-682, at Lines 802-809, and in app/routes/api.boxes.ts at Lines 167-172. Three copies of the 2D/3D rule will drift.

Add one exported helper and reuse it in all three places.

♻️ Proposed helper
// app/lib/location.ts
export function toGeoJsonPosition(
	longitude: number,
	latitude: number,
	height: number | null | undefined,
): [number, number] | [number, number, number] {
	return height == null
		? [longitude, latitude]
		: [longitude, latitude, height]
}
 		for (const device of devices) {
-			const coordinates =
-				device.height === null
-					? [device.longitude, device.latitude]
-					: [device.longitude, device.latitude, device.height]
+			const coordinates = toGeoJsonPosition(
+				device.longitude,
+				device.latitude,
+				device.height,
+			)
 			const feature = point(coordinates, device)

Also applies to: 802-809

tests/routes/api.boxes.spec.ts (1)

355-355: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider asserting the coordinate arity against the feature height.

expect([2, 3]).toContain(...) accepts both shapes unconditionally. A regression that drops the height from the coordinates still passes. Tie the expected length to feature.properties.height.

♻️ Proposed assertion
-				expect([2, 3]).toContain(feature.geometry.coordinates.length)
+				expect(feature.geometry.coordinates.length).toBe(
+					feature.properties.height === null ? 2 : 3,
+				)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 468a7a42-9e5c-45c9-9aa8-08641e7eee6a

📥 Commits

Reviewing files that changed from the base of the PR and between 6da7f40 and 3168052.

📒 Files selected for processing (26)
  • app/components/device/new/location-info.tsx
  • app/components/device/new/new-device-stepper.tsx
  • app/components/device/new/summary-info.tsx
  • app/db/drizzle/0048_giant_carnage.sql
  • app/db/drizzle/meta/0048_snapshot.json
  • app/db/drizzle/meta/_journal.json
  • app/db/models/device.server.ts
  • app/db/models/sensor.server.ts
  • app/db/schema/device.ts
  • app/lib/api-schemas/devices.ts
  • app/lib/device-transform.ts
  • app/lib/location.ts
  • app/lib/openapi/schemas/device.ts
  • app/lib/openapi/schemas/location.ts
  • app/routes/api.boxes.ts
  • app/routes/device.$deviceId.edit.location.tsx
  • app/routes/device.new.tsx
  • app/services/device-service.server.ts
  • public/locales/de/edit-device-general.json
  • public/locales/de/newdevice.json
  • public/locales/en/edit-device-general.json
  • public/locales/en/newdevice.json
  • tests/lib/location.spec.ts
  • tests/lib/transform-to-api-format.spec.ts
  • tests/routes/api.boxes.$deviceId.spec.ts
  • tests/routes/api.boxes.spec.ts

Included review availability: Your plan provides up to 3 included reviews per hour; 2 remain after this review.

@jona159
jona159 marked this pull request as ready for review August 19, 2026 06:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/db/models/device.server.ts (1)

363-363: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Allow null in the update type.

updateDevice stores height: null when null is explicitly provided at Lines 470-472. The UpdateDeviceArgs.location.height type accepts only number, so typed callers cannot clear an existing height.

-	location?: { lat: number; lng: number; height?: number }
+	location?: { lat: number; lng: number; height?: number | null }
🧹 Nitpick comments (1)
app/db/models/device.server.ts (1)

470-472: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add regression tests for height preservation and clearing.

When height is omitted, the existing value must remain unchanged. When height is explicitly null, the stored value must be cleared. Add tests for both update cases.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5aacfd67-1a7d-4da8-8594-7166b09b65d6

📥 Commits

Reviewing files that changed from the base of the PR and between 3168052 and c21cdb9.

📒 Files selected for processing (3)
  • app/db/models/device.server.ts
  • app/lib/location.ts
  • tests/routes/api.boxes.spec.ts

Included review availability: Your plan provides up to 3 included reviews per hour; 1 remains after this review.

Comment thread tests/routes/api.boxes.spec.ts
jona159 and others added 17 commits August 19, 2026 13:55
…in elevation

- Add OpenTopoData API service for fetching terrain elevation
- Uses multi-dataset query (eudem25m,srtm30m) for automatic fallback
- Update location-info.tsx to display terrain elevation and final height
- Users now input height above ground, terrain elevation is auto-fetched
- Final height above sea level is calculated and stored
- Update translations for new height labels and info text
- Update device.new.tsx action to fetch elevation and calculate final height

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
app/lib/api-schemas/devices.ts (1)

18-27: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Document the breaking change to location.height semantics for API clients.

CreateDeviceSchema is the public create-device request shape. Before this change, height was stored as supplied. Now the server resolves terrain elevation and adds it, per the description on DeviceHeightAboveGroundSchema in app/lib/openapi/schemas/location.ts lines 69-73.

An existing client that sends an absolute sea-level height keeps working, but the stored height shifts upward by the terrain elevation at that coordinate. The change is silent for that client.

Add a migration note to the OpenAPI description and the changelog. State the old meaning, the new meaning, and the effective date.

app/routes/device.new.tsx (1)

129-140: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Device creation and integration creation are not atomic, and the new 500 response invites duplicate devices.

Line 129 creates the device. Line 131 creates the integrations. If line 131 throws, the device already exists, but the action now returns device_creation_failed with status 500 at lines 136-139. Previously it redirected to the profile.

The client sees a total failure and the user retries. Each retry creates another device.

Either run both writes in one transaction, or return a success response that reports the integration failure separately so the user does not retry the device creation.

app/routes/api.boxes.$deviceId.ts (1)

450-477: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

A read-modify-write round trip inflates the stored height.

The PUT handler now treats body.location.height as height above ground and stores terrainElevation + height. The GET handler returns the stored height, which is above sea level. A client that reads a device, changes one unrelated field, and sends the payload back unchanged submits the sea-level height as an above-ground height. The stored height then grows by the terrain elevation on every such round trip. This is the normal read-modify-write pattern for a REST resource, so the corruption is easy to trigger.

Consider one of these options:

  • Accept an explicit field, for example heightAboveGround, and keep height as the absolute value.
  • Return the submitted above-ground height in the response so a round trip is idempotent.
  • Reject a location.height that is already resolved, using a request flag.

Also confirm how the edit UI reads the stored height. If app/routes/device.$deviceId.edit.location.tsx seeds its above-ground input from the persisted sea-level device.height, the same inflation occurs on each save.

#!/bin/bash
# Description: Trace how stored device height is read back into above-ground inputs.
fd -t f 'device.$deviceId.edit.location.tsx' app/routes --exec rg -n 'height|heightAboveGround|elevation' {}
fd -t f 'location.ts' app/lib/openapi/schemas --exec cat -n {}
rg -n 'heightAboveGround|height' app/lib/device-transform.ts app/services/device-service.server.ts
app/routes/api.boxes.ts (1)

186-189: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a nullish check for device.height.

The condition tests only null. If device.height is undefined, for example when a query selects a subset of columns, the third branch produces [longitude, latitude, undefined], which serializes to [lng, lat, null] and is not valid GeoJSON. Test for both null and undefined.

🐛 Proposed fix
 					coordinates:
-						device.height === null
+						device.height == null
 							? [device.longitude, device.latitude]
 							: [device.longitude, device.latitude, device.height],
🧹 Nitpick comments (9)
app/lib/openapi/schemas/location.ts (1)

100-108: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the shared long-form normalization transform.

Lines 100-108 repeat the transform already defined at lines 59-67 for LocationObjectSchema. Both map longitude/latitude/height to lng/lat/height.

Move the transform body into a single local helper and use it in both places.

♻️ Proposed refactor
+function normalizeLongForm(location: {
+	longitude: number
+	latitude: number
+	height?: number
+}) {
+	return { lng: location.longitude, lat: location.latitude, height: location.height }
+}

Then use normalizeLongForm(location) in both transforms.

.env.example (1)

16-19: 🩺 Stability & Availability | 🔵 Trivial

Plan for the public OpenTopoData quota.

The example points at the shared public instance api.opentopodata.org. That instance enforces a low request rate and a daily call cap. Device creation and location edits now block on this lookup, and app/routes/device.new.tsx returns HTTP 503 when the lookup fails.

For production, host a private OpenTopoData instance or add a cache for repeated coordinates. Add a metric for elevation lookup failures so quota exhaustion is visible.

app/components/device/new/summary-info.tsx (1)

20-24: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reuse the elevation result from the location step.

app/components/device/new/location-info.tsx line 15 already calls useTerrainElevation for the same coordinates. This call omits initialResult, so the summary step issues a second request for coordinates that were already resolved. app/routes/device.new.tsx then performs a third lookup on the server. The public OpenTopoData endpoint is rate limited.

Lift the resolved TerrainElevationResult into the stepper form state and pass it as initialResult. The hook already short-circuits when resultMatchesLocation matches.

app/routes/device.new.tsx (1)

71-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate terrainElevation with its type.

let terrainElevation without an annotation gets an evolving implicit any. The access terrainElevation.elevation at line 88 is therefore unchecked. app/routes/device.$deviceId.edit.location.tsx line 162 declares let terrainElevation: TerrainElevationResult.

♻️ Proposed change
-	let terrainElevation
+	let terrainElevation: TerrainElevationResult

Add the type import:

-import { calculateHeightAboveSeaLevel } from '~/lib/elevation'
+import {
+	calculateHeightAboveSeaLevel,
+	type TerrainElevationResult,
+} from '~/lib/elevation'
app/lib/location.ts (1)

101-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared field-error mapping.

parseDeviceLocationInputFormData and validateDeviceLocationInputFieldErrors build the same DeviceLocationInputFieldErrors object from a flattened error. Extract one helper and reuse it in both functions.

♻️ Proposed refactor
+function toDeviceLocationInputFieldErrors(
+	error: z.ZodError,
+): DeviceLocationInputFieldErrors {
+	const flattened = z.flattenError(error)
+
+	return {
+		latitude: flattened.fieldErrors.latitude?.[0],
+		longitude: flattened.fieldErrors.longitude?.[0],
+		heightAboveGround: flattened.fieldErrors.heightAboveGround?.[0],
+	}
+}
+
 export function parseDeviceLocationInputFormData(formData: FormData):
 	| {
 			success: true
 			data: DeviceLocationInput
 	  }
 	| {
 			success: false
 			errors: DeviceLocationInputFieldErrors
 	  } {
 	const parsed = deviceLocationInputSchema.safeParse({
 		latitude: formData.get('latitude'),
 		longitude: formData.get('longitude'),
 		heightAboveGround: formData.get('heightAboveGround'),
 	})
 
 	if (parsed.success) return { success: true, data: parsed.data }
 
-	const flattened = z.flattenError(parsed.error)
-
-	return {
-		success: false,
-		errors: {
-			latitude: flattened.fieldErrors.latitude?.[0],
-			longitude: flattened.fieldErrors.longitude?.[0],
-			heightAboveGround: flattened.fieldErrors.heightAboveGround?.[0],
-		},
-	}
+	return {
+		success: false,
+		errors: toDeviceLocationInputFieldErrors(parsed.error),
+	}
 }
 
 export function validateDeviceLocationInputFieldErrors(
 	value: unknown,
 ): DeviceLocationInputFieldErrors {
 	const parsed = deviceLocationInputSchema.safeParse(value)
 
 	if (parsed.success) return {}
 
-	const flattened = z.flattenError(parsed.error)
-
-	return {
-		latitude: flattened.fieldErrors.latitude?.[0],
-		longitude: flattened.fieldErrors.longitude?.[0],
-		heightAboveGround: flattened.fieldErrors.heightAboveGround?.[0],
-	}
+	return toDeviceLocationInputFieldErrors(parsed.error)
 }
app/components/device/new/location-info.tsx (2)

74-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract one numeric field handler.

handleLatitudeChange, handleLongitudeChange, and handleHeightChange repeat the same trim, Number, and Number.isFinite logic. Extract a single helper that parses the input and returns number | undefined, then use it in all three handlers.

♻️ Proposed refactor
+	const parseNumericInput = (rawValue: string) => {
+		const value = rawValue.trim()
+		if (value === '') return undefined
+		const parsedValue = Number(value)
+
+		return Number.isFinite(parsedValue) ? parsedValue : undefined
+	}
+
 	const handleLatitudeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-		const value = event.target.value.trim()
-		const parsedValue = Number(value)
-		const latitude =
-			value === '' || !Number.isFinite(parsedValue) ? '' : parsedValue
+		const parsed = parseNumericInput(event.target.value)
+		const latitude = parsed ?? ''
 
 		setMarker((current) => ({ ...current, latitude }))
-		setValue('latitude', latitude === '' ? undefined : latitude, {
+		setValue('latitude', parsed, {
 			shouldDirty: true,
 			shouldValidate: true,
 		})
 	}

243-291: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Announce elevation status changes and reference only existing element ids.

Two points for the elevation status block:

  • aria-describedby="height-info height-error" always references height-error, but that element renders only when errors.heightAboveGround.message exists. Build the value conditionally.
  • The loading, error, and result text replaces itself asynchronously without a live region. Screen readers do not announce the change. Add aria-live="polite" to a wrapper around the status output.
app/components/device/new/new-device-stepper.tsx (1)

147-162: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard the error toast against repeated firing.

The effect depends on t. react-i18next returns a new t identity after a language change, so the same failed actionData shows the toast again. Track the handled actionData in a ref, or depend only on actionData.

♻️ Proposed refactor
+	const handledActionDataRef = useRef<unknown>(null)
+
 	useEffect(() => {
 		if (!actionData || actionData.ok) return
+		if (handledActionDataRef.current === actionData) return
+		handledActionDataRef.current = actionData
 
 		toast({
 			title: t('device_creation_error'),
 			description: t(actionData.error),
 			variant: 'destructive',
 		})
 	}, [actionData, t, toast])
app/routes/resources.elevation.ts (1)

42-50: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Map lookup error codes to more precise statuses.

rate_limited and timeout both return 503. Return 429 for rate_limited, with Retry-After, and 504 for timeout. Clients can then apply correct backoff instead of retrying every failure the same way.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e546959-39c1-4a61-8565-df9577de21de

📥 Commits

Reviewing files that changed from the base of the PR and between c21cdb9 and e8b1eb5.

📒 Files selected for processing (22)
  • .env.example
  • README.md
  • app/components/device/new/location-info.tsx
  • app/components/device/new/new-device-stepper.tsx
  • app/components/device/new/summary-info.tsx
  • app/hooks/use-terrain-elevation.ts
  • app/lib/api-schemas/devices.ts
  • app/lib/elevation.ts
  • app/lib/env.server.ts
  • app/lib/location.ts
  • app/lib/openapi/schemas/location.ts
  • app/routes/api.boxes.$deviceId.ts
  • app/routes/api.boxes.ts
  • app/routes/device.$deviceId.edit.location.tsx
  • app/routes/device.new.tsx
  • app/routes/resources.elevation.ts
  • app/services/elevation-service.server.ts
  • public/locales/de/edit-device-general.json
  • public/locales/de/newdevice.json
  • public/locales/en/edit-device-general.json
  • public/locales/en/newdevice.json
  • tests/lib/location.spec.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • public/locales/de/edit-device-general.json
  • public/locales/en/edit-device-general.json
  • public/locales/de/newdevice.json

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment thread app/components/device/new/summary-info.tsx Outdated
Comment thread app/components/device/new/summary-info.tsx
Comment thread app/hooks/use-terrain-elevation.ts
Comment thread app/lib/openapi/schemas/location.ts
Comment thread app/routes/device.$deviceId.edit.location.tsx Outdated
Comment thread app/routes/device.$deviceId.edit.location.tsx
Comment thread app/routes/device.new.tsx
Comment thread app/routes/device.new.tsx Outdated
Comment thread app/services/elevation-service.server.ts
Comment thread public/locales/en/newdevice.json Outdated
@zven

zven commented Sep 1, 2026

Copy link
Copy Markdown
Member

I can't change the location of a device when the height API is not reachable:

Screenshot 2026-09-01 at 11 44 03

@zven

zven commented Sep 1, 2026

Copy link
Copy Markdown
Member

Whe the height API is not reachable, creating a new device is also not possible:

Screenshot 2026-09-01 at 11 45 42 Screenshot 2026-09-01 at 11 45 13

@jona159 and @scheidtdav We need a fallback solution for both cases.

Creation:

  • When no height can be retreived we simply store no height and the device can still be created?
  • When the user enters a height above ground, we store that?

Editing:

  • I can still change the location but height is not updated?
  • We store the height above ground instead?

@zven

zven commented Sep 1, 2026

Copy link
Copy Markdown
Member

With this feature, we are effectively submitting the coordinates of the device that is created or updated to an external service (www.opentopodata.org) via their API (api.opentopodata.org/v1).

Hence, we need to explicitly obtain and store the consent of our users before we send this data. I'd suggest a checkbox in the area marked in red with a text like: I confirm that the coordinates are transmitted to the service OpenTopoData to retrieve the device height above sea level. Details see privacy policy [link to privacy policy].

Screenshot 2026-09-01 at 14 53 19

When the user checks the checkbox, their consent is stored in their profile, the height is obtained and the user can add the height above ground.

For the next device the create and in the device settings menu, the same checkbox is shown (and automatically selected when they have already given their consent).

: t('elevation_consent_required')
: elevation.status === 'loading'
? t('fetching_elevation')
: t('elevation_unavailable'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four nested ternary operators?! That's pretty much unreadable.

>) {
const [existingDevice] = await drizzleClient
.select()
.select({ id: device.id, archivedAt: device.archivedAt })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the archivedAt suddenly coming from?

Comment thread app/db/schema/device.ts
longitude: doublePrecision('longitude').notNull(),
height: doublePrecision('height'),
heightAboveGround: doublePrecision('height_above_ground'),
terrainElevation: doublePrecision('terrain_elevation'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe nitpicking here: Do we really need double precision? Although the UI accepts values like 1,5, we only show integer values and the open topo api also only returns full meters:

Image

description: 'Device height above sea level in meters',
description:
'Device height above sea level in meters. Kept as a legacy alias for heightAboveSeaLevel.',
example: 66.6,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do decimals make sense here?

"height_info_text": "Höhe relativ zur geschätzten Bodenoberfläche in Metern. Wenn das Feld leer bleibt, wird Bodenniveau angenommen; negative Werte stehen für Installationen unter der Oberfläche. Gespeichert wird die berechnete Höhe über dem Meeresspiegel.",
"calculating_height_above_ground": "Höhe über dem Boden wird aus der gespeicherten Höhe berechnet...",
"height_info_text": "Höhe relativ zur geschätzten Bodenoberfläche in Metern. Lasse das Feld leer, wenn keine Gerätehöhe gespeichert werden soll. Wenn verfügbar, wird aus der geschätzten Geländehöhe die Höhe über dem Meeresspiegel berechnet.",
"elevation_lookup_consent": "Ich willige ein, dass die Koordinaten des Geräts an OpenTopoData übermittelt werden, um die Höhe über dem Meeresspiegel zu ermitteln. Weitere Informationen enthält die <privacyLink>Datenschutzerklärung</privacyLink>.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread public/locales/de/newdevice.json Outdated
"height_info_text": "Höhe relativ zur geschätzten Bodenoberfläche in Metern. Wenn das Feld leer bleibt, wird Bodenniveau angenommen; negative Werte stehen für Installationen unter der Oberfläche. Gespeichert wird die berechnete Höhe über dem Meeresspiegel.",
"height_info_label": "Weitere Informationen zur Höhe über dem Boden",
"height_info_text": "Höhe relativ zur geschätzten Bodenoberfläche in Metern. Lasse das Feld leer, wenn keine Gerätehöhe gespeichert werden soll. Wenn verfügbar, wird aus der geschätzten Geländehöhe die Höhe über dem Meeresspiegel berechnet.",
"elevation_lookup_consent": "Ich willige ein, dass die Koordinaten des Geräts an OpenTopoData übermittelt werden, um die Höhe über dem Meeresspiegel zu ermitteln. Weitere Informationen enthält die <privacyLink>Datenschutzerklärung</privacyLink>.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link to https://www.opentopodata.org/? See comment above

"height_info_text": "Height relative to the estimated ground surface in meters. Leave blank to assume that the device is at ground level; negative values represent below-ground installations. The calculated height above sea level is stored.",
"calculating_height_above_ground": "Calculating height above ground from the stored height...",
"height_info_text": "Height relative to the estimated ground surface in meters. Leave blank if no device height should be stored. When available, the estimated terrain elevation is used to calculate the height above sea level.",
"elevation_lookup_consent": "I consent to the transmission of the device coordinates to OpenTopoData to retrieve the height above sea level. See the <privacyLink>privacy policy</privacyLink> for details.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above

Comment thread public/locales/en/newdevice.json Outdated
"height_info_text": "Height relative to the estimated ground surface in meters. Leave blank to assume that the device is at ground level; negative values represent below-ground installations. The calculated height above sea level is stored.",
"height_info_label": "More information about height above ground",
"height_info_text": "Height relative to the estimated ground surface in meters. Leave blank if no device height should be stored. When available, the estimated terrain elevation is used to calculate the height above sea level.",
"elevation_lookup_consent": "I consent to the transmission of the device coordinates to OpenTopoData to retrieve the height above sea level. See the <privacyLink>privacy policy</privacyLink> for details.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user can only withdraw the elevation consent but not grant it via this endtpoint? 🤔

elevation: device.terrainElevation,
dataset: device.terrainElevationDataset ?? 'unknown',
datum: null,
attribution: null,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are datum and attribution doing?

)
}

await grantCurrentElevationConsent(userId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No explicit check if body.consent is true?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

add device location height

2 participants