Sync the Fly App's waypoint routes through the API Server

Routes saved in the Fly App's mission library now sync to a new per-user
routes collection on the API Server, in the WaypointMissionSpec JSON
format. The library pulls on load and pushes on every save and delete,
merges last-writer-wins by updatedAt, keeps unacknowledged deletes as
tombstones, and keeps working offline. Each signed-in account has its
own copy on the device; routes saved while signed out go to the first
account that signs in.

The API Server gets list / get / upsert / delete route endpoints with a
PocketBase migration, and the Web App a read-only Routes view on the
dashboard (rebuilt dist).

Tested against a fake PocketBase and a fake server only; not yet tried
with a real sign-in on the tablet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-20 17:26:45 +02:00
1 parent e49f318a0e
commit 93f4a580e9
18 files changed
+1525 -43

No files matched your search

+15
View File
@@ -418,6 +418,21 @@ export function exportLogbookUrl() {
return '/bff/logbook/export'
}
/* ---------- Routes ---------- */
// List the saved waypoint routes in scope (own; an admin's whole org). Each
// carries the Fly App's route JSON in `spec`.
export async function getRoutes() {
try {
const r = await fetch('/bff/routes')
if (!r.ok) return { ok: false, status: r.status, routes: [] }
const d = await r.json()
return { ok: true, status: 200, routes: d.routes || [] }
} catch {
return { ok: false, status: 0, routes: [] }
}
}
/* ---------- Documents ---------- */
// List in-scope documents. Pass `expiring` (days) to narrow to documents that
+5 -1
View File
@@ -7,6 +7,7 @@ import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import Drones from './Drones.vue'
import Documents from './Documents.vue'
import RoutesView from './Routes.vue'
import Toggle from './settings/Toggle.vue'
import { getDevices, sendCommand, getOpenSkyStates, getOpenWeatherCurrent, autoAddDrone } from '../api.js'
import { formatTime, prefs } from '../prefs.js'
@@ -1060,13 +1061,16 @@ onBeforeUnmount(() => {
<Drones v-else-if="active === 'Drones'" ref="dronesView" :connected-fc-serials="connectedFcSerials"
@deleted="forgetIdentity" />
<!-- ---------- Routes ---------- -->
<RoutesView v-else-if="active === 'Routes'" />
<!-- ---------- Documents ---------- -->
<Documents v-else-if="active === 'Documents'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
<!-- ---------- Settings ---------- -->
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
<!-- ---------- Placeholder views (Routes / Schedule / Drives) ---------- -->
<!-- ---------- Placeholder views (Schedule / Drives) ---------- -->
<div v-else class="p-7">
<div class="panel grid place-items-center p-16 text-center">
<Icon :name="activeIcon" :size="28" class="text-ink-muted" />
+176
View File
@@ -0,0 +1,176 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import Icon from './Icon.vue'
import { getRoutes } from '../api.js'
// Read-only view of the waypoint routes pilots save in the Fly App's mission
// library. The tablet is where routes are drawn and edited; this lists what has
// synced. Scoping (own routes; an admin sees the org's) is server-side.
const routes = ref([])
const loading = ref(false)
const loadErr = ref('')
const openId = ref('')
async function loadAll() {
loading.value = true
loadErr.value = ''
const res = await getRoutes()
if (!res.ok) {
loadErr.value =
res.status === 503
? 'Route storage is not configured on the API Server (service account missing).'
: 'Could not load your routes.'
}
routes.value = res.routes
loading.value = false
}
onMounted(loadAll)
// Great-circle metres between two {lat, lon} points.
function metres(a, b) {
const R = 6371000
const rad = (d) => (d * Math.PI) / 180
const dLat = rad(b.lat - a.lat)
const dLon = rad(b.lon - a.lon)
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function points(r) {
return Array.isArray(r.spec?.points) ? r.spec.points : []
}
function length(r) {
const p = points(r)
let m = 0
for (let i = 1; i < p.length; i++) m += metres(p[i - 1], p[i])
return m
}
function fmtLength(m) {
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
// Flight time at cruise speed (ignores hovers and turns): a rough guide only.
function fmtDuration(r) {
const v = Number(r.spec?.cruiseSpeed) || 0
if (!v) return '—'
const s = Math.round(length(r) / v)
return s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`
}
function fmtWhen(iso) {
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString()
}
const FINISH = { goHome: 'Return to home', hover: 'Hover', land: 'Land', firstWaypoint: 'Back to first WP' }
const ACTION = { none: '—', photo: 'Photo', startRecord: 'Start recording', stopRecord: 'Stop recording' }
const stats = computed(() => {
const all = routes.value
return {
count: all.length,
mine: all.filter((r) => r.mine).length,
waypoints: all.reduce((n, r) => n + points(r).length, 0),
distance: all.reduce((m, r) => m + length(r), 0),
}
})
</script>
<template>
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
<div class="flex flex-wrap items-center gap-3">
<div>
<div class="eyebrow">Mission library</div>
<div class="mt-0.5 text-base font-semibold text-ink">Waypoint routes</div>
</div>
<div class="ml-auto flex items-center gap-2">
<button class="btn-ghost inline-flex items-center gap-2" :disabled="loading" @click="loadAll">
<Icon name="download" :size="14" /> Refresh
</button>
</div>
</div>
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
<div v-for="s in [
{ label: 'Routes', value: stats.count },
{ label: 'Yours', value: stats.mine },
{ label: 'Waypoints', value: stats.waypoints },
{ label: 'Total distance', value: fmtLength(stats.distance) },
]" :key="s.label" class="panel p-5">
<div class="eyebrow">{{ s.label }}</div>
<div class="mt-2 text-[30px] font-bold leading-none tracking-tightest text-ink">{{ s.value }}</div>
</div>
</div>
<div v-if="loadErr" class="panel border-danger/40 p-4 text-sm text-danger-fg">{{ loadErr }}</div>
<div class="panel overflow-hidden p-0">
<div v-if="loading && !routes.length" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!routes.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="route" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">No routes yet</div>
<div class="mt-1 text-xs text-ink-muted">
Save a route in the Fly App's Waypoints mission library while signed in and it shows up here.
</div>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th v-for="h in ['Route', 'Waypoints', 'Length', 'Est. time', 'Speed', 'On finish', 'Updated']" :key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted">
{{ h }}
</th>
</tr>
</thead>
<tbody>
<template v-for="r in routes" :key="r.id">
<tr class="cursor-pointer border-b border-line last:border-0 hover:bg-surface-2"
:class="openId === r.id ? 'bg-accent-soft' : ''" @click="openId = openId === r.id ? '' : r.id">
<td class="px-5 py-3">
<div class="flex items-center gap-2">
<Icon name="chevronRight" :size="13" class="text-ink-muted transition"
:class="openId === r.id ? 'rotate-90' : ''" />
<span class="font-semibold text-ink">{{ r.name || 'Route' }}</span>
<span v-if="!r.mine" class="inline-flex rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-semibold text-ink-secondary">
another pilot
</span>
</div>
</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ points(r).length }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ fmtLength(length(r)) }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ fmtDuration(r) }}</td>
<td class="px-5 py-3 font-mono text-xs text-ink-secondary">{{ r.spec?.cruiseSpeed ?? '—' }} m/s</td>
<td class="px-5 py-3 text-xs text-ink-secondary">{{ FINISH[r.spec?.finishAction] || r.spec?.finishAction || '—' }}</td>
<td class="whitespace-nowrap px-5 py-3 text-xs text-ink-muted">{{ fmtWhen(r.updatedAt) }}</td>
</tr>
<tr v-if="openId === r.id" class="border-b border-line bg-surface-2">
<td colspan="7" class="px-5 py-3">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-ink-muted">
<th v-for="h in ['#', 'Latitude', 'Longitude', 'Altitude', 'Speed', 'Gimbal', 'Action']" :key="h"
class="py-1 pr-4 font-mono text-[10.5px] font-normal uppercase tracking-caps">{{ h }}</th>
</tr>
</thead>
<tbody class="font-mono text-ink-secondary">
<tr v-for="(p, i) in points(r)" :key="i">
<td class="py-1 pr-4">{{ i + 1 }}</td>
<td class="py-1 pr-4">{{ Number(p.lat).toFixed(6) }}</td>
<td class="py-1 pr-4">{{ Number(p.lon).toFixed(6) }}</td>
<td class="py-1 pr-4">{{ p.altitude }} m</td>
<td class="py-1 pr-4">{{ p.speed != null ? `${p.speed} m/s` : 'cruise' }}</td>
<td class="py-1 pr-4">{{ p.gimbalPitch != null ? `${p.gimbalPitch}°` : '—' }}</td>
<td class="py-1 pr-4">
{{ ACTION[p.action] || p.action || '—' }}{{ p.hoverSeconds ? ` · hover ${p.hoverSeconds} s` : '' }}
</td>
</tr>
</tbody>
</table>
<div class="mt-2 text-xs text-ink-muted">Edit this route in the Fly App; changes sync here.</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>