Compare commits

...
4 Commits
Author SHA1 Message Date
elpatron 66bdb5ef41 Merge pull request 'feat(live): prefill course from GPS heading in Live Log.' (#1) from feature/live-log-course-gps-prefill into master
Reviewed-on: #1
2026-07-12 10:53:39 +02:00
elpatron 9dfaa154ca chore(env): add DATABASE_URL for local development setup. 2026-07-12 10:52:16 +02:00
elpatronandCursor 8b51d920da chore(dev): harden start-dev shutdown and DATABASE_URL setup.
Load root .env for Prisma, stop dev servers via process groups, and sync server lockfile metadata after install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:51:26 +02:00
elpatronandCursor 4d00b15f7b feat(live): prefill course from GPS heading in Live Log.
Query device COG when opening the course dialog, mirroring SOG prefill, with fallback to the last logged course.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:48:06 +02:00
13 changed files with 154 additions and 20 deletions
+3
View File
@@ -28,6 +28,9 @@ ORIGIN=http://localhost:5173
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1 # Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
# TRUST_PROXY=1 # TRUST_PROXY=1
# Local dev (./scripts/start-dev.sh): Prisma + backend API
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
# Docker Compose database (required for production deploy) # Docker Compose database (required for production deploy)
# Generate: openssl rand -hex 24 # Generate: openssl rand -hex 24
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md) # Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
+24 -6
View File
@@ -73,6 +73,7 @@ import {
type GeolocationErrorReason, type GeolocationErrorReason,
type GpsSignalQuality type GpsSignalQuality
} from '../utils/geolocation.js' } from '../utils/geolocation.js'
import { formatCourseAngle } from '../utils/courseAngle.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { import {
dedupeSailNames, dedupeSailNames,
@@ -633,16 +634,18 @@ export default function LiveLogView({
setModal(type) setModal(type)
} }
const liveLogGpsPrefillOptions = {
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 60_000
} as const
const openSogModal = async () => { const openSogModal = async () => {
let prefill = '' let prefill = ''
try { try {
const permission = await queryGeolocationPermission() const permission = await queryGeolocationPermission()
if (permission === 'granted') { if (permission === 'granted') {
const pos = await getCurrentPosition({ const pos = await getCurrentPosition(liveLogGpsPrefillOptions)
timeoutMs: 8000,
enableHighAccuracy: false,
maximumAge: 60_000
})
if (pos.speedKn != null) prefill = String(pos.speedKn) if (pos.speedKn != null) prefill = String(pos.speedKn)
} }
} catch { } catch {
@@ -651,6 +654,20 @@ export default function LiveLogView({
openValueModal('sog', prefill) openValueModal('sog', prefill)
} }
const openCourseModal = async () => {
let prefill = lastCourseFromEvents(events)
try {
const permission = await queryGeolocationPermission()
if (permission === 'granted') {
const pos = await getCurrentPosition(liveLogGpsPrefillOptions)
if (pos.headingDeg != null) prefill = formatCourseAngle(pos.headingDeg, true)
}
} catch {
// Fall back to last logged course or manual entry
}
openValueModal('course', prefill)
}
const handleMotorToggle = () => { const handleMotorToggle = () => {
hapticPulse() hapticPulse()
const starting = !motorRunning const starting = !motorRunning
@@ -1433,7 +1450,7 @@ export default function LiveLogView({
onCastOff={handleCastOff} onCastOff={handleCastOff}
onMoor={handleMoor} onMoor={handleMoor}
onOpenSails={() => { setSelectedSails([]); setModal('sails') }} onOpenSails={() => { setSelectedSails([]); setModal('sails') }}
onOpenCourse={() => openValueModal('course', lastCourseFromEvents(events))} onOpenCourse={() => void openCourseModal()}
onOpenSog={() => void openSogModal()} onOpenSog={() => void openSogModal()}
onOpenStw={() => openValueModal('stw')} onOpenStw={() => openValueModal('stw')}
onOpenFuel={() => openValueModal('fuel')} onOpenFuel={() => openValueModal('fuel')}
@@ -1796,6 +1813,7 @@ export default function LiveLogView({
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}> <div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal live-log-modal--dial" onClick={(e) => e.stopPropagation()}> <div className="live-log-modal live-log-modal--dial" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_course_btn')}</h3> <h3>{t('logs.live_course_btn')}</h3>
<p className="live-log-modal-hint">{t('logs.live_course_hint')}</p>
<div className="live-log-dial-field"> <div className="live-log-dial-field">
<label>{t('logs.event_mgk')}</label> <label>{t('logs.event_mgk')}</label>
<CourseDialInput <CourseDialInput
+1
View File
@@ -389,6 +389,7 @@
"live_sog_placeholder": "f.eks. 5,2", "live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8", "live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Afstand over jordoverfladen (kn) — GPS-værdien udfyldes automatisk, hvis den er tilgængelig.", "live_sog_hint": "Afstand over jordoverfladen (kn) — GPS-værdien udfyldes automatisk, hvis den er tilgængelig.",
"live_course_hint": "Kurs over jord (°) — GPS-værdien udfyldes automatisk, hvis den er tilgængelig.",
"delete_entry": "Slet dag", "delete_entry": "Slet dag",
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag endeligt?", "delete_confirm": "Er du sikker på, at du vil slette denne rejsedag endeligt?",
"carry_over_tanks_title": "Skal data fra i går overføres?", "carry_over_tanks_title": "Skal data fra i går overføres?",
+1
View File
@@ -395,6 +395,7 @@
"live_sog_placeholder": "z. B. 5,2", "live_sog_placeholder": "z. B. 5,2",
"live_stw_placeholder": "z. B. 4,8", "live_stw_placeholder": "z. B. 4,8",
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.", "live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
"live_course_hint": "Kurs über Grund (°) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
"delete_entry": "Tag löschen", "delete_entry": "Tag löschen",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?", "delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?", "carry_over_tanks_title": "Daten vom Vortag übernehmen?",
+1
View File
@@ -395,6 +395,7 @@
"live_sog_placeholder": "e.g. 5.2", "live_sog_placeholder": "e.g. 5.2",
"live_stw_placeholder": "e.g. 4.8", "live_stw_placeholder": "e.g. 4.8",
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.", "live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
"live_course_hint": "Course over ground (°) — prefilled from GPS when available.",
"delete_entry": "Delete Day", "delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?", "delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?", "carry_over_tanks_title": "Carry over from previous day?",
+1
View File
@@ -389,6 +389,7 @@
"live_sog_placeholder": "p. ej., 5,2", "live_sog_placeholder": "p. ej., 5,2",
"live_stw_placeholder": "p. ej., 4,8", "live_stw_placeholder": "p. ej., 4,8",
"live_sog_hint": "Distancia recorrida (km) — El valor del GPS se rellena automáticamente si está disponible.", "live_sog_hint": "Distancia recorrida (km) — El valor del GPS se rellena automáticamente si está disponible.",
"live_course_hint": "Rumbo sobre el fondo (°) — El valor del GPS se rellena automáticamente si está disponible.",
"delete_entry": "Eliminar etiqueta", "delete_entry": "Eliminar etiqueta",
"delete_confirm": "¿Estás seguro de que quieres eliminar definitivamente este día de viaje?", "delete_confirm": "¿Estás seguro de que quieres eliminar definitivamente este día de viaje?",
"carry_over_tanks_title": "¿Importar los datos del día anterior?", "carry_over_tanks_title": "¿Importar los datos del día anterior?",
+1
View File
@@ -389,6 +389,7 @@
"live_sog_placeholder": "par exemple 5,2", "live_sog_placeholder": "par exemple 5,2",
"live_stw_placeholder": "par exemple 4,8", "live_stw_placeholder": "par exemple 4,8",
"live_sog_hint": "Vitesse par rapport au sol (kn) — La valeur GPS est préremplie si elle est disponible.", "live_sog_hint": "Vitesse par rapport au sol (kn) — La valeur GPS est préremplie si elle est disponible.",
"live_course_hint": "Cap sur le fond (°) — La valeur GPS est préremplie si elle est disponible.",
"delete_entry": "Supprimer la balise", "delete_entry": "Supprimer la balise",
"delete_confirm": "Es-tu sûr de vouloir supprimer définitivement cette date de voyage ?", "delete_confirm": "Es-tu sûr de vouloir supprimer définitivement cette date de voyage ?",
"carry_over_tanks_title": "Reprendre les données de la veille ?", "carry_over_tanks_title": "Reprendre les données de la veille ?",
+1
View File
@@ -389,6 +389,7 @@
"live_sog_placeholder": "f.eks. 5,2", "live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8", "live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Fart over grunn (kn) — GPS-verdien fylles ut automatisk hvis den er tilgjengelig.", "live_sog_hint": "Fart over grunn (kn) — GPS-verdien fylles ut automatisk hvis den er tilgjengelig.",
"live_course_hint": "Kurs over grunn (°) — GPS-verdien fylles ut automatisk hvis den er tilgjengelig.",
"delete_entry": "Slett dag", "delete_entry": "Slett dag",
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen uten mulighet for å angre?", "delete_confirm": "Er du sikker på at du vil slette denne reisedagen uten mulighet for å angre?",
"carry_over_tanks_title": "Skal data fra i går overføres?", "carry_over_tanks_title": "Skal data fra i går overføres?",
+1
View File
@@ -389,6 +389,7 @@
"live_sog_placeholder": "t.ex. 5,2", "live_sog_placeholder": "t.ex. 5,2",
"live_stw_placeholder": "t.ex. 4,8", "live_stw_placeholder": "t.ex. 4,8",
"live_sog_hint": "Avstånd över marken (kn) — GPS-värdet fylls i automatiskt om det finns tillgängligt.", "live_sog_hint": "Avstånd över marken (kn) — GPS-värdet fylls i automatiskt om det finns tillgängligt.",
"live_course_hint": "Kurs över mark (°) — GPS-värdet fylls i automatiskt om det finns tillgängligt.",
"delete_entry": "Ta bort dag", "delete_entry": "Ta bort dag",
"delete_confirm": "Är du säker på att du vill radera den här resedagen utan möjlighet att ångra dig?", "delete_confirm": "Är du säker på att du vill radera den här resedagen utan möjlighet att ångra dig?",
"carry_over_tanks_title": "Överföra data från föregående dag?", "carry_over_tanks_title": "Överföra data från föregående dag?",
+18
View File
@@ -77,11 +77,29 @@ describe('geolocation helpers', () => {
lat: '59.910000', lat: '59.910000',
lng: '10.750000', lng: '10.750000',
speedKn: 4.9, speedKn: 4.9,
headingDeg: null,
accuracyM: 12, accuracyM: 12,
signalQuality: 'excellent' signalQuality: 'excellent'
}) })
}) })
it('resolves heading from getCurrentPosition when reported', async () => {
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: (success: PositionCallback) => {
success({
coords: { latitude: 59.91, longitude: 10.75, heading: 245.6, accuracy: 8 }
} as GeolocationPosition)
}
}
})
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toMatchObject({
headingDeg: 246,
signalQuality: 'excellent'
})
})
it('formats GPS accuracy for display', () => { it('formats GPS accuracy for display', () => {
expect(formatGpsAccuracyMeters(12.4)).toBe('12') expect(formatGpsAccuracyMeters(12.4)).toBe('12')
expect(formatGpsAccuracyMeters(87)).toBe('87') expect(formatGpsAccuracyMeters(87)).toBe('87')
+9
View File
@@ -18,6 +18,8 @@ export interface GeoCoordinates {
lng: string lng: string
/** SOG from GPS when available (kn), otherwise null. */ /** SOG from GPS when available (kn), otherwise null. */
speedKn: number | null speedKn: number | null
/** COG from GPS when available (degrees, 0359), otherwise null. */
headingDeg: number | null
/** Estimated horizontal accuracy in metres, when reported by the browser. */ /** Estimated horizontal accuracy in metres, when reported by the browser. */
accuracyM: number | null accuracyM: number | null
/** Derived signal quality indicator for UI hints. */ /** Derived signal quality indicator for UI hints. */
@@ -142,10 +144,16 @@ function normalizeGetPositionOptions(
} }
} }
function normalizeHeadingDeg(heading: number | null | undefined): number | null {
if (heading == null || !Number.isFinite(heading)) return null
return Math.round(((heading % 360) + 360) % 360)
}
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates { function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed) const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1)) ? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null : null
const headingDeg = normalizeHeadingDeg(pos.coords.heading)
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy) const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
? pos.coords.accuracy ? pos.coords.accuracy
: null : null
@@ -153,6 +161,7 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
lat: formatAppCoordinate(pos.coords.latitude), lat: formatAppCoordinate(pos.coords.latitude),
lng: formatAppCoordinate(pos.coords.longitude), lng: formatAppCoordinate(pos.coords.longitude),
speedKn, speedKn,
headingDeg,
accuracyM, accuracyM,
signalQuality: classifyGpsAccuracyMeters(accuracyM) signalQuality: classifyGpsAccuracyMeters(accuracyM)
} }
+92 -14
View File
@@ -38,6 +38,32 @@ resolve_node_toolchain() {
command -v npm >/dev/null 2>&1 command -v npm >/dev/null 2>&1
} }
load_root_env() {
local env_file="$REPO_ROOT/.env"
if [ ! -f "$env_file" ]; then
return 0
fi
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
}
resolve_database_url() {
if [ -n "${DATABASE_URL:-}" ]; then
printf '%s' "$DATABASE_URL"
return 0
fi
local user="${POSTGRES_USER:-postgres}"
local password="${POSTGRES_PASSWORD:-}"
local db="${POSTGRES_DB:-daagbox}"
[ -z "$user" ] && user=postgres
[ -z "$password" ] && password=postgres
[ -z "$db" ] && db=daagbox
printf 'postgresql://%s:%s@localhost:5432/%s?schema=public' "$user" "$password" "$db"
}
check_dev_env() { check_dev_env() {
local env_file="$REPO_ROOT/.env" local env_file="$REPO_ROOT/.env"
if [ ! -f "$env_file" ]; then if [ ! -f "$env_file" ]; then
@@ -91,38 +117,84 @@ echo "========================================"
echo "Preparing to (re)start services..." echo "Preparing to (re)start services..."
require_node_toolchain require_node_toolchain
load_root_env
export DATABASE_URL="$(resolve_database_url)"
check_dev_env check_dev_env
# Clean up processes running on ports # Clean up processes running on ports
stop_process_tree() {
local pid=$1
[ -z "$pid" ] && return 0
if ! kill -0 "$pid" 2>/dev/null; then
return 0
fi
local child
for child in $(pgrep -P "$pid" 2>/dev/null || true); do
stop_process_tree "$child"
done
kill -TERM "$pid" 2>/dev/null || true
}
stop_process_group() {
local pgid=$1
[ -z "$pgid" ] && return 0
kill -TERM "-$pgid" 2>/dev/null || true
}
force_stop_process_group() {
local pgid=$1
[ -z "$pgid" ] && return 0
kill -KILL "-$pgid" 2>/dev/null || true
}
cleanup_port() { cleanup_port() {
local port=$1 local port=$1
if command -v lsof >/dev/null 2>&1; then if command -v lsof >/dev/null 2>&1; then
local pid=$(lsof -t -i:$port) local pids
if [ ! -z "$pid" ]; then pids=$(lsof -t -i:"$port" 2>/dev/null || true)
echo "Port $port is currently in use by PID $pid. Stopping process..." if [ -n "$pids" ]; then
kill -9 $pid 2>/dev/null echo "Port $port is currently in use. Stopping process(es)..."
local pid
for pid in $pids; do
stop_process_tree "$pid"
done
sleep 0.3
for pid in $pids; do
kill -KILL "$pid" 2>/dev/null || true
done
fi fi
elif command -v fuser >/dev/null 2>&1; then elif command -v fuser >/dev/null 2>&1; then
echo "Port $port is currently in use. Stopping process..." echo "Port $port is currently in use. Stopping process..."
fuser -k $port/tcp 2>/dev/null fuser -k "$port"/tcp 2>/dev/null
fi fi
} }
cleanup_port $SERVER_PORT BACKEND_PGID=""
cleanup_port $CLIENT_PORT CLIENT_PGID=""
# Clean exit handler
cleanup_all() { cleanup_all() {
echo "" echo ""
echo "Stopping all dev servers..." echo "Stopping all dev servers..."
# Kill all child jobs
kill $(jobs -p) 2>/dev/null stop_process_group "$BACKEND_PGID"
stop_process_group "$CLIENT_PGID"
sleep 0.5
force_stop_process_group "$BACKEND_PGID"
force_stop_process_group "$CLIENT_PGID"
cleanup_port "$SERVER_PORT"
cleanup_port "$CLIENT_PORT"
exit 0 exit 0
} }
# Trap termination signals # Trap termination signals
trap cleanup_all SIGINT SIGTERM EXIT trap cleanup_all SIGINT SIGTERM EXIT
cleanup_port "$SERVER_PORT"
cleanup_port "$CLIENT_PORT"
# Manage PostgreSQL Docker container # Manage PostgreSQL Docker container
if command -v docker >/dev/null 2>&1; then if command -v docker >/dev/null 2>&1; then
echo "Checking PostgreSQL Docker container (postgres-daagbox)..." echo "Checking PostgreSQL Docker container (postgres-daagbox)..."
@@ -189,8 +261,9 @@ if [ ! -d node_modules ]; then
echo "Error: server/node_modules missing. Run: cd server && npm ci" echo "Error: server/node_modules missing. Run: cd server && npm ci"
exit 1 exit 1
fi fi
npm run dev & setsid npm run dev &
BACKEND_PID=$! BACKEND_PID=$!
BACKEND_PGID=$BACKEND_PID
cd "$REPO_ROOT" || exit 1 cd "$REPO_ROOT" || exit 1
# Sleep briefly to let server start up # Sleep briefly to let server start up
@@ -205,7 +278,9 @@ echo "Starting frontend dev server..."
cd "$REPO_ROOT/client" || exit 1 cd "$REPO_ROOT/client" || exit 1
if [ ! -d node_modules ]; then if [ ! -d node_modules ]; then
echo "Error: client/node_modules missing. Run: cd client && npm ci" echo "Error: client/node_modules missing. Run: cd client && npm ci"
kill "$BACKEND_PID" 2>/dev/null stop_process_group "$BACKEND_PGID"
sleep 0.3
force_stop_process_group "$BACKEND_PGID"
exit 1 exit 1
fi fi
# Vite 6+ via plugin-react 4; refresh lockfile after package.json changes # Vite 6+ via plugin-react 4; refresh lockfile after package.json changes
@@ -213,14 +288,17 @@ if ! node -e "require.resolve('vite/package.json')" 2>/dev/null; then
echo "Client dependencies incomplete — running npm ci..." echo "Client dependencies incomplete — running npm ci..."
npm ci || exit 1 npm ci || exit 1
fi fi
npm run dev & setsid npm run dev &
CLIENT_PID=$! CLIENT_PID=$!
CLIENT_PGID=$CLIENT_PID
cd "$REPO_ROOT" || exit 1 cd "$REPO_ROOT" || exit 1
sleep 1.5 sleep 1.5
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
echo "Error: Frontend dev server exited immediately. Check client logs above." echo "Error: Frontend dev server exited immediately. Check client logs above."
kill "$BACKEND_PID" 2>/dev/null stop_process_group "$BACKEND_PGID"
sleep 0.3
force_stop_process_group "$BACKEND_PGID"
exit 1 exit 1
fi fi
+1
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "server", "name": "server",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/client": "^5.10.2", "@prisma/client": "^5.10.2",
"@simplewebauthn/server": "^9.0.3", "@simplewebauthn/server": "^9.0.3",