').text(value ?? '').html();
}
-
function persistRouteProgress() {
if (!state.route) return;
if (state.persistTimer != null) {
@@ -54,7 +55,6 @@
state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
? 'paused'
: 'idle';
-
if (state.routeState === 'paused') {
$('#tracking-status')
.prop('hidden', false)
@@ -89,7 +89,6 @@
target.removeAttr('hidden');
state.activePage = pageSelector;
globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
-
if (options.updateHistory === false) return;
const historyState = { page: pageSelector, previous };
if (options.replace) history.replaceState(historyState, '', pageSelector);
@@ -112,31 +111,99 @@
].filter(Boolean).join(' ')).includes(searchTerm);
}
- function renderRouteItems(listSelector, routes, emptyText) {
+ function routeButton(route, source) {
+ const proximity = route.proximityM == null
+ ? ''
+ : `
${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`;
+ const sourceLabel = source === 'local' ? '
Lokaler Entwurf' : '';
+ return `
+
+
+
+ `;
+ }
+
+ function renderRouteItems(listSelector, routes, emptyText, source = 'server') {
const list = $(listSelector).empty();
const items = Array.isArray(routes) ? routes.filter(Boolean) : [];
if (!items.length) {
list.append(`
${escapeHtml(emptyText)}
`);
return;
}
+ items.forEach(route => list.append(routeButton(route, source)));
+ }
- items.forEach(route => {
- const proximity = route.proximityM == null
- ? ''
- : `
${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`;
+ function nextPoiMetric() {
+ const pois = state.route?.pois || [];
+ const pending = pois
+ .filter(poi => !state.triggered.has(poi.id))
+ .slice()
+ .sort((left, right) => Number(left.sequence ?? 0) - Number(right.sequence ?? 0));
+ if (!pending.length) return null;
+ if (!state.position) return { poi: pending[0], distance: null };
+ return nearestPendingPoi(poiMetrics(state.position));
+ }
+ function updateRouteControlSummary() {
+ const title = $('#route-control-title');
+ const next = $('#route-control-next');
+ if (!state.route) {
+ title.text('Route wählen');
+ next.text('Aktive und lokale Strecken');
+ return;
+ }
+
+ title.text(state.route.name || 'Route');
+ const metric = nextPoiMetric();
+ if (!metric) {
+ next.text((state.route.pois || []).length ? 'Alle POIs erreicht' : 'Keine POIs vorhanden');
+ return;
+ }
+ const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`;
+ next.text(`Nächster POI: ${metric.poi.title}${distance}`);
+ }
+
+ function renderRouteControlList(selector, routes, source, emptyText) {
+ const list = $(selector).empty();
+ if (!routes.length) {
+ list.append(`
${escapeHtml(emptyText)}`);
+ return;
+ }
+ routes.forEach(route => {
+ const current = state.route && String(state.route.id) === String(route.id)
+ && (state.route.source || 'server') === source;
list.append(`
-
`);
});
}
+ function renderRouteControl() {
+ updateRouteControlSummary();
+ renderRouteControlList(
+ '#route-control-server-list',
+ state.routes,
+ 'server',
+ 'Keine veröffentlichte Strecke vorhanden.'
+ );
+ renderRouteControlList(
+ '#route-control-local-list',
+ state.localRoutes,
+ 'local',
+ state.localRoutesError || 'Noch keine lokale Strecke vorhanden.'
+ );
+ }
+
function renderRoutes() {
const routes = Array.isArray(state.routes) ? state.routes.filter(Boolean) : [];
const radiusM = ns.Config.routeRadiusKm * 1000;
@@ -152,6 +219,9 @@
.filter(route => routeMatchesSearch(route, searchTerm))
.slice()
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
+ const localRoutes = state.localRoutes
+ .slice()
+ .sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
const nearbyEmptyText = state.position
? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.`
@@ -161,20 +231,26 @@
renderRouteItems(
'#all-route-list',
allRoutes,
- searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'
+ searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine veröffentlichten Routen vorhanden.'
+ );
+ renderRouteItems(
+ '#local-route-list',
+ localRoutes,
+ state.localRoutesError || 'Noch keine lokalen Strecken gespeichert.',
+ 'local'
);
$('#all-routes-summary').text(
searchTerm
- ? `${allRoutes.length} von ${routes.length} Routen gefunden.`
- : `${routes.length} Routen verfügbar.`
+ ? `${allRoutes.length} von ${routes.length} veröffentlichten Routen gefunden.`
+ : `${routes.length} veröffentlichte Routen verfügbar.`
);
+ renderRouteControl();
}
function initializeRouteSearch() {
const input = $('#route-search');
if (!input.length || typeof input.autocomplete !== 'function') return;
-
input.autocomplete({
minLength: 0,
source(request, response) {
@@ -215,7 +291,6 @@
const running = state.routeState === 'running';
const paused = state.routeState === 'paused';
const startLabel = paused ? 'Route fortsetzen' : 'Route starten';
-
$('#start-route')
.prop('disabled', running)
.attr({ 'aria-label': startLabel, title: startLabel });
@@ -226,7 +301,6 @@
function setActivePoi(poiId) {
$('#poi-list [data-poi-id]').removeAttr('aria-current');
if (poiId == null) return;
-
$(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
}
@@ -243,7 +317,6 @@
ns.AudioPlayer.unload();
setActivePoi(null);
hideNavigation();
-
if (announce && wasActive && !returnToRoutes) {
$('#tracking-status').prop('hidden', false).text('Route beendet.');
} else {
@@ -251,16 +324,18 @@
}
updateRouteControls();
-
if (returnToRoutes) {
state.route = null;
+ updateRouteControlSummary();
+ renderRouteControl();
navigate('#routes-page', { replace: true });
+ } else {
+ updateRouteControlSummary();
}
}
function pauseRoute() {
if (state.routeState !== 'running') return;
-
state.routeState = 'paused';
persistRouteProgress();
stopRouteSensors();
@@ -270,32 +345,37 @@
.text('Route pausiert. Die Standortverfolgung ist angehalten.');
showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
updateRouteControls();
+ updateRouteControlSummary();
}
- function renderRoute(route) {
- if (state.routeState !== 'idle') endRoute({ announce: false });
-
- state.route = route;
+ function suspendCurrentRoute() {
+ if (!state.route) return;
+ if (state.routeState === 'running') state.routeState = 'paused';
+ persistRouteProgress();
+ stopRouteSensors();
+ ns.AudioPlayer.unload();
state.activePoiId = null;
- restoreRouteProgress(route);
setActivePoi(null);
+ hideNavigation();
+ }
+
+ function renderRouteView(route) {
$('#route-title').text(route.name);
$('#route-description').text(route.description || '');
$('#route-distance').text(ns.Distance.format(route.distanceM));
- $('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
+ $('#route-elevation').text(`${Math.round(route.elevationGainM || 0)} m`);
$('#route-poi-count').text(route.pois.length);
updateRouteControls();
-
const list = $('#poi-list').empty();
if (!route.pois.length) {
list.append('
Diese Route enthält noch keine Stationen.
');
+ updateRouteControlSummary();
return;
}
-
route.pois.forEach(poi => {
list.append(`
-
+
${escapeHtml(poi.title)}
${escapeHtml(poi.description || '')}
@@ -308,8 +388,18 @@
`);
});
-
updatePoiIndicators(state.position);
+ updateRouteControlSummary();
+ }
+
+ function renderRoute(route) {
+ if (state.route) suspendCurrentRoute();
+ state.route = route;
+ state.activePoiId = null;
+ restoreRouteProgress(route);
+ setActivePoi(null);
+ renderRouteView(route);
+ renderRouteControl();
}
function showPoi(poi) {
@@ -334,7 +424,6 @@
start = Math.max(0, state.routeProgressIndex - 3);
end = Math.min(points.length, state.routeProgressIndex + 201);
}
-
let nearestIndex = start;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let index = start; index < end; index += 1) {
@@ -344,7 +433,6 @@
nearestIndex = index;
}
}
-
return nearestIndex;
}
@@ -360,7 +448,6 @@
function poiMetrics(position) {
if (!position || !state.route?.pois?.length) return [];
const heading = effectiveHeading(position);
-
return state.route.pois.map(poi => {
const distance = ns.Distance.meters(position, poi);
const bearing = ns.Distance.bearing(position, poi);
@@ -377,7 +464,6 @@
function updatePoiIndicators(position) {
const metrics = poiMetrics(position);
-
metrics.forEach(metric => {
const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`);
const distance = Math.round(metric.distance);
@@ -387,7 +473,6 @@
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
indicator.find('small').text(`${distance} m`);
});
-
return metrics;
}
@@ -403,20 +488,17 @@
const previousProgressIndex = state.routeProgressIndex;
const points = state.route?.points || [];
if (!points.length) return null;
-
const nearestIndex = nearestRoutePointIndex(position);
if (nearestIndex == null) return null;
state.routeProgressIndex = state.routeProgressIndex == null
? nearestIndex
: Math.max(state.routeProgressIndex, nearestIndex);
-
const lastIndex = points.length - 1;
let targetIndex = Math.min(lastIndex, state.routeProgressIndex + 1);
let target = points[targetIndex];
let distance = ns.Distance.meters(position, target);
const reachedRadius = Math.max(4, Math.min(12, Math.round(position.accuracy || 4)));
-
while (targetIndex < lastIndex && distance <= reachedRadius) {
state.routeProgressIndex = targetIndex;
targetIndex += 1;
@@ -429,7 +511,6 @@
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
return { reached: true, target, distance: 0 };
}
-
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
return { reached: false, target, distance };
}
@@ -437,7 +518,6 @@
function showPoiNavigation(position, metric) {
const heading = effectiveHeading(position);
const distance = Math.round(metric.distance);
-
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
showNavigation(
heading == null
@@ -464,14 +544,12 @@
$('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
return;
}
-
const bearing = ns.Distance.bearing(position, progress.target);
const heading = effectiveHeading(position);
const rotation = heading == null
? bearing
: ns.Distance.normalizeDegrees(bearing - heading);
const distance = Math.round(progress.distance);
-
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
showNavigation(
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
@@ -485,11 +563,9 @@
function updateNavigation(position) {
if (state.routeState !== 'running') return;
-
const progress = updateRouteProgress(position);
const metrics = updatePoiIndicators(position);
const nextPoi = nearestPendingPoi(metrics);
-
if (nextPoi) showPoiNavigation(position, nextPoi);
else showRoutePointNavigation(position, progress);
}
@@ -501,37 +577,36 @@
const delta = ((heading - state.deviceHeading + 540) % 360) - 180;
state.deviceHeading = ns.Distance.normalizeDegrees(state.deviceHeading + delta * 0.25);
}
-
if (state.position) updateNavigation(state.position);
}
function evaluatePosition(position) {
state.position = position;
+ updateRouteProximity(position);
+ renderRouteControl();
if (!state.route) return;
-
const metrics = updatePoiIndicators(position);
+ updateRouteControlSummary();
if (state.routeState !== 'running') return;
const nearest = nearestPendingPoi(metrics);
updateNavigation(position);
-
$('#tracking-status').text(
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
+ (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
);
-
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
state.triggered.add(nearest.poi.id);
persistRouteProgress();
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
showPoi(nearest.poi);
updateNavigation(position);
+ updateRouteControlSummary();
}
}
async function startRoute() {
if (!state.route || state.routeState === 'running') return;
-
const resumed = state.routeState === 'paused';
if (!resumed) {
state.routeProgressIndex = null;
@@ -542,11 +617,11 @@
state.deviceHeading = null;
persistRouteProgress();
updateRouteControls();
+ updateRouteControlSummary();
$('#tracking-status')
.prop('hidden', false)
.text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.');
showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt');
-
ns.Geo.start(
evaluatePosition,
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
@@ -557,24 +632,35 @@
ns.Orientation.stop();
return;
}
-
- if (!orientationAvailable) {
- $('#navigation-mode').text('Norden oben · Navigation');
- }
+ if (!orientationAvailable) $('#navigation-mode').text('Norden oben · Navigation');
}
function routesFromResponse(response) {
if (!Array.isArray(response?.routes)) {
throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.');
}
- return response.routes.filter(route => route?.id != null && route?.name != null);
+ return response.routes
+ .filter(route => route?.id != null && route?.name != null)
+ .map(route => ({ ...route, source: 'server', local: false }));
+ }
+
+ function loadLocalRoutes() {
+ try {
+ state.localRoutes = ns.LocalRoutes?.listAppRoutes?.() || [];
+ state.localRoutesError = null;
+ } catch (error) {
+ state.localRoutes = [];
+ state.localRoutesError = error.message || 'Lokale Strecken konnten nicht gelesen werden.';
+ }
}
function updateRouteProximity(position) {
- state.routes = state.routes.map(route => ({
+ const withProximity = route => ({
...route,
proximityM: route.start ? ns.Distance.meters(position, route.start) : null
- }));
+ });
+ state.routes = state.routes.map(withProximity);
+ state.localRoutes = state.localRoutes.map(withProximity);
}
async function initializeLocation(context) {
@@ -597,7 +683,38 @@
}
function restoreRouteLists() {
- if (state.routes.length) renderRoutes();
+ loadLocalRoutes();
+ if (state.position) updateRouteProximity(state.position);
+ renderRoutes();
+ }
+
+ function setRouteControlOpen(open) {
+ $('#route-control-dropdown').prop('hidden', !open);
+ $('#route-control-button').attr('aria-expanded', String(open));
+ }
+
+ async function selectRoute(source, id) {
+ setRouteControlOpen(false);
+ const route = source === 'local'
+ ? ns.LocalRoutes.getAppRoute(id)
+ : { ...(await ns.Api.route(id)), source: 'server', local: false };
+ renderRoute(route);
+ navigate('#route-page');
+ }
+
+ function refreshCurrentLocalRoute(id) {
+ if (!state.route || state.route.source !== 'local' || String(state.route.id) !== String(id)) return;
+ persistRouteProgress();
+ try {
+ state.route = ns.LocalRoutes.getAppRoute(id);
+ renderRouteView(state.route);
+ renderRouteControl();
+ } catch {
+ state.route = null;
+ state.routeState = 'idle';
+ hideNavigation();
+ navigate('#routes-page', { replace: true });
+ }
}
function bindEvents() {
@@ -607,13 +724,11 @@
$('#route-search').val('').trigger('input').trigger('focus');
});
- activate('[data-route-id]', async function () {
+ activate('[data-route-select]', async function () {
const button = $(this);
button.prop('disabled', true);
try {
- const route = await ns.Api.route(button.data('route-id'));
- renderRoute(route);
- navigate('#route-page');
+ await selectRoute(String(button.data('route-source') || 'server'), String(button.data('route-id')));
} catch (error) {
alert(error.responseJSON?.message || error.statusText || error.message);
} finally {
@@ -621,7 +736,7 @@
}
});
- activate('[data-poi-id]', function () {
+ activate('[data-poi-select]', function () {
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
if (poi) showPoi(poi);
});
@@ -632,14 +747,33 @@
else navigate(target, { replace: true });
});
+ activate('#route-control-button', function () {
+ setRouteControlOpen($('#route-control-dropdown').prop('hidden'));
+ });
+
+ activate('#route-control-new', function () {
+ setRouteControlOpen(false);
+ ns.EditorUi?.openNewLocalRoute?.();
+ });
+
+ activate('#editor-button', function () {
+ setRouteControlOpen(false);
+ });
+
+ $(document).on('pointerup', event => {
+ if ($('#route-control-dropdown').prop('hidden')) return;
+ if ($(event.target).closest('.route-control').length) return;
+ setRouteControlOpen(false);
+ });
+
+ $(document).on('keydown', event => {
+ if (event.key === 'Escape') setRouteControlOpen(false);
+ });
+
activate('#start-route', async function () {
await startRoute();
});
-
- activate('#pause-route', function () {
- pauseRoute();
- });
-
+ activate('#pause-route', pauseRoute);
activate('#stop-route', function () {
endRoute({ returnToRoutes: true });
});
@@ -654,12 +788,19 @@
if (event.detail?.enabled) persistRouteProgress();
});
+ document.addEventListener('wegwichtel:local-routes-changed', event => {
+ loadLocalRoutes();
+ if (state.position) updateRouteProximity(state.position);
+ refreshCurrentLocalRoute(event.detail?.id);
+ renderRoutes();
+ ns.EditorUi?.refresh?.();
+ });
+
globalThis.addEventListener('pagehide', persistRouteProgress);
globalThis.addEventListener('popstate', event => {
const page = event.state?.page || '#routes-page';
navigate(page, { updateHistory: false, instant: true });
});
-
globalThis.addEventListener('pageshow', restoreRouteLists);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') restoreRouteLists();
@@ -669,6 +810,9 @@
ns.App = {
navigate,
+ currentPosition() {
+ return state.position ? { ...state.position } : null;
+ },
async initialize(context) {
bindEvents();
@@ -677,12 +821,14 @@
const response = await ns.Api.routes(null);
state.routes = routesFromResponse(response);
+ loadLocalRoutes();
context.markStep('routes', 'done');
renderRoutes();
initializeRouteSearch();
updateRouteControls();
hideNavigation();
+ setRouteControlOpen(false);
await initializeLocation(context);
}
diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js
index 412b782..7106bd5 100644
--- a/public/js/editor-ui.js
+++ b/public/js/editor-ui.js
@@ -6,10 +6,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const ids = Object.freeze({
addPoi: 'editor-add-poi',
button: 'editor-button',
- coordinateGrid: 'editor-coordinate-grid',
close: 'editor-close',
dialog: 'editor-dialog',
- entryTitle: 'editor-entry-title',
newRoute: 'editor-new-route',
poiAudio: 'editor-poi-audio',
poiCancel: 'editor-poi-cancel',
@@ -23,53 +21,33 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
poiList: 'editor-poi-list',
poiLon: 'editor-poi-lon',
poiRadius: 'editor-poi-radius',
- poiSave: 'editor-poi-save',
poiTitle: 'editor-poi-title',
routeActivated: 'editor-route-activated',
routeBack: 'editor-route-back',
routeDescription: 'editor-route-description',
- routeDetailTitle: 'editor-route-detail-title',
routeDetailView: 'editor-route-detail-view',
routeForm: 'editor-route-form',
routeGpx: 'editor-route-gpx',
routeId: 'editor-route-id',
routeList: 'editor-route-list',
- routeListTitle: 'editor-route-list-title',
routeListView: 'editor-route-list-view',
routeName: 'editor-route-name',
- routeSave: 'editor-route-save',
- status: 'editor-status',
- title: 'editor-dialog-title'
+ status: 'editor-status'
});
-
const classes = Object.freeze({
- button: 'editor-button',
- dialog: 'editor-dialog',
- dialogHeader: 'editor-dialog-header',
- dialogShell: 'editor-dialog-shell',
- entryPanel: 'editor-entry-panel',
- form: 'editor-form',
iconButton: 'editor-icon-button',
iconRow: 'editor-icon-row',
muted: 'editor-muted',
poiItem: 'editor-poi-item',
- poiList: 'editor-poi-list',
- poiToolbar: 'editor-poi-toolbar',
- primaryButton: 'editor-primary-button',
- routeItem: 'editor-route-item',
- routeList: 'editor-route-list',
- roundButton: 'editor-round-button',
- secondaryButton: 'editor-secondary-button',
- status: 'editor-status',
- toolbar: 'editor-toolbar',
- toolbarBack: 'editor-toolbar-back',
- view: 'editor-view'
+ routeGroup: 'editor-route-group',
+ routeItem: 'editor-route-item'
});
const state = {
currentPoi: null,
currentRoute: null,
+ currentSource: null,
pois: [],
routes: []
};
@@ -194,6 +172,22 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
});
}
+ async function parseJson(response) {
+ const raw = await response.text();
+ if (!raw.trim()) return {};
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return {};
+ }
+ }
+
+ async function handleResponse(response) {
+ if (response.ok) return parseJson(response);
+ const message = await response.text();
+ throw new Error(message || `HTTP ${response.status}`);
+ }
+
async function readJson(path) {
const response = await fetch(apiUrl(path), {
credentials: 'same-origin',
@@ -225,22 +219,6 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return handleResponse(response);
}
- async function handleResponse(response) {
- if (response.ok) return parseJson(response);
- const message = await response.text();
- throw new Error(message || `HTTP ${response.status}`);
- }
-
- async function parseJson(response) {
- const raw = await response.text();
- if (!raw.trim()) return {};
- try {
- return JSON.parse(raw);
- } catch {
- return {};
- }
- }
-
function routeArray(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.routes)) return payload.routes;
@@ -273,55 +251,71 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return button;
}
+ function routeSource(route) {
+ return route?._source || route?.source || 'server';
+ }
+
function routeItem(route) {
const item = document.createElement('li');
item.className = classes.routeItem;
item.dataset.routeId = routeId(route);
+ item.dataset.routeSource = routeSource(route);
const label = document.createElement('div');
appendText(label, 'strong', routeName(route));
- appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
+ appendText(label, 'small', routeSource(route) === 'local' ? 'Lokaler Entwurf' : 'Veröffentlichte Strecke');
const actions = document.createElement('div');
actions.className = classes.iconRow;
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
-
item.append(label, actions);
return item;
}
+ function routeGroup(title) {
+ const item = document.createElement('li');
+ item.className = classes.routeGroup;
+ item.textContent = title;
+ return item;
+ }
+
function poiItem(poi, index) {
const item = document.createElement('li');
item.className = classes.poiItem;
item.dataset.poiId = poiId(poi);
-
const title = poiTitle(poi);
const label = document.createElement('div');
appendText(label, 'strong', title);
appendText(label, 'small', `Position ${index + 1}`);
-
const actions = document.createElement('div');
actions.className = classes.iconRow;
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1));
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
-
item.append(label, actions);
return item;
}
+ function renderRouteGroup(list, title, routes, emptyText) {
+ list.appendChild(routeGroup(title));
+ if (!routes.length) {
+ const item = document.createElement('li');
+ item.className = classes.routeItem;
+ appendText(item, 'span', emptyText);
+ list.appendChild(item);
+ return;
+ }
+ routes.forEach(route => list.appendChild(routeItem(route)));
+ }
+
function renderRouteList() {
const list = byId(ids.routeList);
if (!list) return;
list.textContent = '';
- if (!state.routes.length) {
- const item = document.createElement('li');
- item.className = classes.routeItem;
- appendText(item, 'span', 'Noch keine Strecken gefunden.');
- list.appendChild(item);
- return;
- }
- state.routes.forEach(route => list.appendChild(routeItem(route)));
+ const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
+ const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
+ renderRouteGroup(list, 'Veröffentlichte Strecken', serverRoutes, 'Keine veröffentlichte Strecke vorhanden.');
+ renderRouteGroup(list, 'Lokale Strecken', localRoutes, 'Noch kein lokaler Entwurf vorhanden.');
}
function renderPoiList() {
@@ -338,12 +332,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
}
+ function localRouteSummaries() {
+ if (!ns.LocalRoutes) return [];
+ return ns.LocalRoutes.list().map(routePackage => ({
+ ...routePackage.route,
+ id: routePackage.id,
+ _source: 'local'
+ }));
+ }
+
async function loadRoutes() {
setStatus('Strecken werden geladen …');
- const payload = await readJson('/routes');
- state.routes = routeArray(payload);
+ const payload = await readJson('/routes?activatedOnly=true');
+ const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
+ let localRoutes = [];
+ try {
+ localRoutes = localRouteSummaries();
+ } catch (error) {
+ setStatus(error.message, true);
+ }
+ state.routes = [...serverRoutes, ...localRoutes];
renderRouteList();
- setStatus('');
+ if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus('');
}
async function openEditor() {
@@ -358,6 +368,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await guarded(loadRoutes);
}
+ async function openNewLocalRoute() {
+ await openEditor();
+ newRoute();
+ }
+
function closeEditor() {
const dialog = byId(ids.dialog);
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
@@ -377,11 +392,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return {
description: byId(ids.poiDescription)?.value.trim() || '',
lat: numberOrNull(byId(ids.poiLat)?.value),
- latitude: numberOrNull(byId(ids.poiLat)?.value),
lon: numberOrNull(byId(ids.poiLon)?.value),
- longitude: numberOrNull(byId(ids.poiLon)?.value),
- radiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
- sortOrder: state.currentPoi?.sortOrder ?? state.pois.length,
+ triggerRadiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
+ sequence: state.currentPoi?.sequence ?? state.pois.length,
title: byId(ids.poiTitle)?.value.trim() || ''
};
}
@@ -391,9 +404,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
byId(ids.routeDescription).value = text(route?.description);
byId(ids.routeGpx).value = '';
- byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
- ? `Aktiviert am ${route.activatedAt || route.activated_at}. Änderung der Freigabe erfolgt weiterhin manuell in der Datenbank.`
- : 'Nicht veröffentlicht: activated_at ist leer und wird durch den Editor nicht gesetzt.';
+ if (state.currentSource === 'local') {
+ byId(ids.routeActivated).textContent = 'Lokaler Entwurf: GPX, Texte und Medien bleiben ausschließlich im Browser. Eine Server-Freigabe ist noch nicht implementiert.';
+ } else {
+ byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
+ ? `Veröffentlicht am ${route.activatedAt || route.activated_at}. Der Editor verändert die Freigabe nicht.`
+ : 'Server-Strecke ohne Freigabedatum. Der Editor verändert activated_at nicht.';
+ }
}
function fillPoiForm(poi) {
@@ -402,7 +419,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
- byId(ids.poiRadius).value = text(poi?.radiusM ?? poi?.radius ?? 25);
+ byId(ids.poiRadius).value = text(poi?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25);
byId(ids.poiAudio).value = '';
byId(ids.poiImages).value = '';
}
@@ -411,23 +428,47 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const id = routeId(route);
if (!id) return;
setStatus('Streckendetails werden geladen …');
- const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
- const detail = payload.route || payload;
- state.currentRoute = detail;
- state.pois = poiArray(detail);
- fillRouteForm(detail);
+ state.currentSource = routeSource(route);
+
+ if (state.currentSource === 'local') {
+ const routePackage = ns.LocalRoutes.get(id);
+ if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
+ state.currentRoute = {
+ ...routePackage.route,
+ id: routePackage.id,
+ gpx: routePackage.gpx,
+ _source: 'local'
+ };
+ state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
+ } else {
+ const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
+ const detail = payload.route || payload;
+ state.currentRoute = { ...detail, _source: 'server' };
+ state.pois = poiArray(detail);
+ }
+
+ state.currentPoi = null;
+ fillRouteForm(state.currentRoute);
renderPoiList();
showRouteView();
setStatus('');
}
function newRoute() {
- state.currentRoute = null;
+ state.currentSource = 'local';
+ state.currentRoute = {
+ id: '',
+ name: '',
+ description: '',
+ gpx: ns.LocalRoutes.emptyGpx('Neue Strecke'),
+ _source: 'local'
+ };
state.currentPoi = null;
state.pois = [];
- fillRouteForm({});
+ fillRouteForm(state.currentRoute);
renderPoiList();
showRouteView();
+ setStatus('Neue Strecke wird als lokaler GPX-/JSON-Entwurf angelegt.');
}
function formDataFromRoute(values) {
@@ -439,52 +480,137 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return data;
}
+ async function saveLocalRoute(values) {
+ const file = byId(ids.routeGpx)?.files?.[0];
+ let gpx = state.currentRoute?.gpx || ns.LocalRoutes.emptyGpx(values.name);
+ if (file) {
+ gpx = await ns.LocalRoutes.readFileAsText(file);
+ const importedPois = ns.LocalRoutes.parseWaypoints(gpx);
+ if (!state.pois.length && importedPois.length) state.pois = importedPois;
+ }
+
+ const id = routeId(state.currentRoute) || ns.LocalRoutes.createRouteId();
+ const saved = ns.LocalRoutes.save({
+ id,
+ gpx,
+ route: {
+ ...(state.currentRoute || {}),
+ id,
+ name: values.name,
+ description: values.description
+ },
+ pois: state.pois
+ });
+ state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
+ state.currentSource = 'local';
+ state.pois = saved.pois;
+ setStatus('Lokaler Entwurf gespeichert. Es wurden keine Daten an den Server übertragen.');
+ await loadRoutes();
+ await loadRoute({ id: saved.id, _source: 'local' });
+ }
+
+ async function saveServerRoute(values) {
+ const id = routeId(state.currentRoute);
+ if (!id) throw new Error('Neue Strecken werden ausschließlich lokal angelegt.');
+ const file = byId(ids.routeGpx)?.files?.[0];
+ const path = `/routes/${encodeURIComponent(id)}`;
+ const payload = file
+ ? await sendForm(path, 'PUT', formDataFromRoute(values))
+ : await sendJson(path, 'PUT', values);
+ const saved = payload.route || payload;
+ state.currentRoute = { ...saved, _source: 'server' };
+ setStatus('Veröffentlichte Strecke gespeichert. activated_at wurde nicht verändert.');
+ await loadRoutes();
+ await loadRoute({ id, _source: 'server' });
+ }
+
async function saveRoute() {
const values = routeFromForm();
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
- const id = byId(ids.routeId).value;
- const file = byId(ids.routeGpx)?.files?.[0];
- const path = id ? `/routes/${encodeURIComponent(id)}` : '/routes';
- const method = id ? 'PUT' : 'POST';
- const payload = file ? await sendForm(path, method, formDataFromRoute(values)) : await sendJson(path, method, values);
- const saved = payload.route || payload;
- state.currentRoute = Object.keys(saved).length ? saved : { ...values, id };
- setStatus('Strecke gespeichert. Neue Strecken bleiben bis zur manuellen Aktivierung unveröffentlicht.');
- await loadRoutes();
- if (routeId(state.currentRoute)) await loadRoute(state.currentRoute);
+ if (state.currentSource === 'server') await saveServerRoute(values);
+ else await saveLocalRoute(values);
+ }
+
+ function currentPosition() {
+ return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
}
function newPoi() {
state.currentPoi = null;
- fillPoiForm({ radiusM: 25 });
+ const position = currentPosition();
+ fillPoiForm({
+ lat: position?.lat ?? '',
+ lon: position?.lon ?? '',
+ triggerRadiusM: 25
+ });
+ byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen';
showPoiView();
}
function editPoi(poi) {
state.currentPoi = poi;
fillPoiForm(poi);
+ byId(ids.poiFormTitle).textContent = 'Station bearbeiten';
showPoiView();
}
- async function savePoi() {
- const route = state.currentRoute;
- const currentRouteId = routeId(route);
- if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
- const values = poiFromForm();
- if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
+ async function mediaFromLocalForm(existingPoi, poiIdentifier) {
+ const audioFile = byId(ids.poiAudio)?.files?.[0];
+ const imageFiles = Array.from(byId(ids.poiImages)?.files || []);
+ const audio = audioFile ? await ns.LocalRoutes.fileToDataUrl(audioFile) : existingPoi?.audio || null;
+ const images = [...(existingPoi?.images || [])];
+ for (const imageFile of imageFiles) {
+ const image = await ns.LocalRoutes.fileToDataUrl(imageFile);
+ images.push({
+ ...image,
+ id: `${poiIdentifier}-image-${Date.now()}-${images.length}`,
+ caption: ''
+ });
+ }
+ return { audio, images };
+ }
- const currentPoiId = byId(ids.poiId).value;
- const path = currentPoiId
- ? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
- : `/routes/${encodeURIComponent(currentRouteId)}/pois`;
- const method = currentPoiId ? 'PUT' : 'POST';
- const payload = await sendJson(path, method, values);
- const saved = payload.poi || payload;
- const savedPoiId = poiId(saved) || currentPoiId;
+ async function persistLocalCurrentRoute() {
+ const id = routeId(state.currentRoute);
+ if (!id) throw new Error('Speichere zuerst die Strecke.');
+ const saved = ns.LocalRoutes.save({
+ id,
+ gpx: state.currentRoute.gpx,
+ route: state.currentRoute,
+ pois: state.pois
+ });
+ state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
+ state.pois = saved.pois;
+ return saved;
+ }
- if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
- setStatus('Station gespeichert.');
- await loadRoute(route);
+ async function saveLocalPoi(values) {
+ if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
+ throw new Error('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.');
+ }
+ const currentId = Number(byId(ids.poiId).value);
+ const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
+ const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi;
+ const media = await mediaFromLocalForm(existing, identifier);
+ const savedPoi = {
+ ...(existing || {}),
+ ...values,
+ ...media,
+ id: identifier,
+ sequence: existing?.sequence ?? state.pois.length
+ };
+ const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
+ if (index >= 0) state.pois[index] = savedPoi;
+ else state.pois.push(savedPoi);
+ await persistLocalCurrentRoute();
+ setStatus('GPX-POI und zugehörige JSON-Mediendaten lokal gespeichert.');
+ await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
+ }
+
+ async function uploadOne(path, file) {
+ const data = new FormData();
+ data.append('file', file);
+ return sendForm(path, 'POST', data);
}
async function uploadPoiMedia(currentRouteId, currentPoiId) {
@@ -496,30 +622,57 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
}
- async function uploadOne(path, file) {
- const data = new FormData();
- data.append('file', file);
- return sendForm(path, 'POST', data);
+ async function saveServerPoi(values) {
+ const currentRouteId = routeId(state.currentRoute);
+ if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
+ const currentPoiId = byId(ids.poiId).value;
+ const path = currentPoiId
+ ? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
+ : `/routes/${encodeURIComponent(currentRouteId)}/pois`;
+ const method = currentPoiId ? 'PUT' : 'POST';
+ const payload = await sendJson(path, method, values);
+ const saved = payload.poi || payload;
+ const savedPoiId = poiId(saved) || currentPoiId;
+ if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
+ setStatus('Station gespeichert.');
+ await loadRoute({ id: currentRouteId, _source: 'server' });
+ }
+
+ async function savePoi() {
+ const values = poiFromForm();
+ if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
+ if (state.currentSource === 'local') await saveLocalPoi(values);
+ else await saveServerPoi(values);
+ }
+
+ function reorderPois(index, offset) {
+ const target = index + offset;
+ if (target < 0 || target >= state.pois.length) return false;
+ const reordered = [...state.pois];
+ [reordered[index], reordered[target]] = [reordered[target], reordered[index]];
+ state.pois = reordered.map((poi, sequence) => ({ ...poi, sequence }));
+ renderPoiList();
+ return true;
}
function swapPois(index, offset) {
- const target = index + offset;
- if (target < 0 || target >= state.pois.length) return;
- const reordered = [...state.pois];
- [reordered[index], reordered[target]] = [reordered[target], reordered[index]];
- state.pois = reordered;
- renderPoiList();
+ if (!reorderPois(index, offset)) return;
guarded(savePoiOrder);
}
async function savePoiOrder() {
const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) return;
+ if (state.currentSource === 'local') {
+ await persistLocalCurrentRoute();
+ setStatus('Lokale POI-Reihenfolge gespeichert.');
+ return;
+ }
const jobs = state.pois.map((poi, index) => {
const currentPoiId = poiId(poi);
if (!currentPoiId) return null;
const path = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
- return sendJson(path, 'PUT', { ...poi, position: index, sortOrder: index });
+ return sendJson(path, 'PUT', { ...poi, sequence: index, sortOrder: index });
}).filter(Boolean);
await Promise.all(jobs);
setStatus('Reihenfolge gespeichert.');
@@ -527,7 +680,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function routeForItem(target) {
const item = target.closest('[data-route-id]');
- return state.routes.find(route => routeId(route) === item?.dataset.routeId);
+ const id = item?.dataset.routeId;
+ const source = item?.dataset.routeSource;
+ return state.routes.find(route => routeId(route) === id && routeSource(route) === source);
}
function poiForItem(target) {
@@ -580,58 +735,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
submit(byId(ids.poiForm), () => guarded(savePoi));
}
-
- function classNames() {
- return Object.values(classes);
- }
-
- function testHooks() {
- return Object.freeze({
- apiUrl,
- classNames,
- closeEditor,
- fillPoiForm,
- fillRouteForm,
- handleResponse,
- isActivation,
- loadRoute,
- loadRoutes,
- newPoi,
- newRoute,
- poiArray,
- poiFromForm,
- poiItem,
- parseJson,
- readJson,
- renderPoiList,
- renderRouteList,
- routeArray,
- routeFromForm,
- routeItem,
- savePoi,
- sendForm,
- sendJson,
- setDialogExpanded,
- setDisabled,
- setStatus,
- savePoiOrder,
- saveRoute,
- state,
- swapPois,
- uploadPoiMedia
- });
- }
-
function editorApi() {
- const api = {
+ return Object.freeze({
close: closeEditor,
- classNames,
open: openEditor,
+ openNewLocalRoute,
refresh: loadRoutes
- };
-
- if (globalThis.WegwichtelEditorTestHooks === true) api.testHooks = testHooks();
- return Object.freeze(api);
+ });
}
function init() {
diff --git a/public/js/local-routes.js b/public/js/local-routes.js
new file mode 100644
index 0000000..97e2dec
--- /dev/null
+++ b/public/js/local-routes.js
@@ -0,0 +1,419 @@
+globalThis.Wegwichtel = globalThis.Wegwichtel || {};
+
+(function (ns) {
+ 'use strict';
+
+ const STORAGE_PREFIX = 'wegwichtel.local-routes.v1';
+ const INDEX_KEY = `${STORAGE_PREFIX}.index`;
+ const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
+ const POI_NAMESPACE = 'urn:wegwichtel:poi';
+ let poiCounter = 0;
+
+ function storage() {
+ const target = globalThis.localStorage;
+ if (!target) throw new Error('Lokaler Browser-Speicher ist auf diesem Gerät nicht verfügbar.');
+ return target;
+ }
+
+ function routeKey(id, suffix) {
+ return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
+ }
+
+ function readIndex() {
+ const raw = storage().getItem(INDEX_KEY);
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.map(String) : [];
+ } catch {
+ return [];
+ }
+ }
+
+ function writeIndex(ids) {
+ storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))]));
+ }
+
+ function createRouteId() {
+ if (globalThis.crypto?.randomUUID) return `local-${globalThis.crypto.randomUUID()}`;
+ return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+ }
+
+ function createPoiId() {
+ poiCounter = (poiCounter + 1) % 1000;
+ return (Date.now() * 1000) + poiCounter;
+ }
+
+ function escapeXml(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+ }
+
+ function emptyGpx(name = 'Neue Strecke') {
+ const safeName = escapeXml(name);
+ return `\n`
+ + `
\n`
+ + ` ${safeName}\n`
+ + ` ${safeName}\n`
+ + '';
+ }
+
+ function parseXml(gpx) {
+ const parser = new DOMParser();
+ const document = parser.parseFromString(String(gpx || ''), 'application/xml');
+ if (document.querySelector('parsererror') || document.documentElement?.localName !== 'gpx') {
+ throw new Error('Die GPX-Datei ist nicht gültig.');
+ }
+ return document;
+ }
+
+ function directChildrenByLocalName(parent, localName) {
+ return Array.from(parent?.children || []).filter(node => node.localName === localName);
+ }
+
+ function firstDescendantByLocalName(parent, localName) {
+ return Array.from(parent?.getElementsByTagName('*') || []).find(node => node.localName === localName) || null;
+ }
+
+ function textOfChild(parent, localName) {
+ return directChildrenByLocalName(parent, localName)[0]?.textContent?.trim() || '';
+ }
+
+ function numberAttribute(node, name) {
+ const value = Number.parseFloat(node?.getAttribute(name));
+ return Number.isFinite(value) ? value : null;
+ }
+
+ function waypointId(node) {
+ const extension = firstDescendantByLocalName(node, 'poiId');
+ const parsed = Number(extension?.textContent);
+ return Number.isSafeInteger(parsed) ? parsed : createPoiId();
+ }
+
+ function parseWaypoints(gpx) {
+ const document = parseXml(gpx);
+ return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
+ id: waypointId(node),
+ title: textOfChild(node, 'name') || `Station ${index + 1}`,
+ description: textOfChild(node, 'desc'),
+ lat: numberAttribute(node, 'lat'),
+ lon: numberAttribute(node, 'lon'),
+ triggerRadiusM: 25,
+ sequence: index,
+ audio: null,
+ images: []
+ })).filter(poi => poi.lat != null && poi.lon != null);
+ }
+
+ function createElement(document, localName, text = null) {
+ const element = document.createElementNS(GPX_NAMESPACE, localName);
+ if (text != null) element.textContent = String(text);
+ return element;
+ }
+
+ function updateMetadataName(document, name) {
+ const root = document.documentElement;
+ let metadata = directChildrenByLocalName(root, 'metadata')[0];
+ if (!metadata) {
+ metadata = createElement(document, 'metadata');
+ root.insertBefore(metadata, root.firstChild);
+ }
+ let nameElement = directChildrenByLocalName(metadata, 'name')[0];
+ if (!nameElement) {
+ nameElement = createElement(document, 'name');
+ metadata.appendChild(nameElement);
+ }
+ nameElement.textContent = name;
+ }
+
+ function syncGpx(gpx, route, pois) {
+ const document = parseXml(gpx || emptyGpx(route?.name));
+ const root = document.documentElement;
+ updateMetadataName(document, route?.name || 'Neue Strecke');
+ Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
+
+ const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
+ orderedPois.forEach((poi, index) => {
+ if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
+ throw new Error(`Für ${poi.title || `Station ${index + 1}`} fehlen gültige Koordinaten.`);
+ }
+ const waypoint = createElement(document, 'wpt');
+ waypoint.setAttribute('lat', String(Number(poi.lat)));
+ waypoint.setAttribute('lon', String(Number(poi.lon)));
+ waypoint.appendChild(createElement(document, 'name', poi.title || `Station ${index + 1}`));
+ if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description));
+ const extensions = createElement(document, 'extensions');
+ const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
+ poiId.textContent = String(poi.id);
+ extensions.appendChild(poiId);
+ waypoint.appendChild(extensions);
+ const routeOrTrack = directChildrenByLocalName(root, 'rte')[0]
+ || directChildrenByLocalName(root, 'trk')[0]
+ || null;
+ root.insertBefore(waypoint, routeOrTrack);
+ });
+
+ return new XMLSerializer().serializeToString(document);
+ }
+
+ function readPackage(id) {
+ const target = storage();
+ const gpx = target.getItem(routeKey(id, 'gpx'));
+ const rawJson = target.getItem(routeKey(id, 'json'));
+ if (!gpx || !rawJson) return null;
+ try {
+ const metadata = JSON.parse(rawJson);
+ return {
+ id: String(id),
+ gpx,
+ route: metadata.route || {},
+ pois: Array.isArray(metadata.pois) ? metadata.pois : [],
+ schemaVersion: metadata.schemaVersion || 1
+ };
+ } catch {
+ return null;
+ }
+ }
+
+ function restoreValue(target, key, value) {
+ if (value == null) target.removeItem(key);
+ else target.setItem(key, value);
+ }
+
+ function notify(id, action) {
+ if (typeof document === 'undefined' || typeof CustomEvent === 'undefined') return;
+ document.dispatchEvent(new CustomEvent('wegwichtel:local-routes-changed', {
+ detail: { action, id: String(id) }
+ }));
+ }
+
+ function savePackage(routePackage) {
+ const target = storage();
+ const id = String(routePackage.id || routePackage.route?.id || createRouteId());
+ const previousIndex = target.getItem(INDEX_KEY);
+ const gpxKey = routeKey(id, 'gpx');
+ const jsonKey = routeKey(id, 'json');
+ const previousGpx = target.getItem(gpxKey);
+ const previousJson = target.getItem(jsonKey);
+ const now = new Date().toISOString();
+ const inputRoute = routePackage.route || {};
+ const route = {
+ id,
+ name: String(inputRoute.name || ''),
+ description: String(inputRoute.description || ''),
+ createdAt: inputRoute.createdAt || now,
+ updatedAt: now
+ };
+ const pois = (routePackage.pois || []).map((poi, index) => ({
+ ...poi,
+ id: Number.isSafeInteger(Number(poi.id)) ? Number(poi.id) : createPoiId(),
+ sequence: index
+ }));
+ const gpx = syncGpx(routePackage.gpx || emptyGpx(route.name), route, pois);
+ const metadata = JSON.stringify({ schemaVersion: 1, route, pois });
+
+ try {
+ target.setItem(gpxKey, gpx);
+ target.setItem(jsonKey, metadata);
+ writeIndex([...readIndex(), id]);
+ } catch (error) {
+ try {
+ restoreValue(target, gpxKey, previousGpx);
+ restoreValue(target, jsonKey, previousJson);
+ restoreValue(target, INDEX_KEY, previousIndex);
+ } catch {
+ // Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
+ }
+ const name = error?.name === 'QuotaExceededError' ? 'Der lokale Browser-Speicher ist voll.' : 'Die lokale Strecke konnte nicht gespeichert werden.';
+ throw new Error(`${name} Bilder und Audiodateien können das Local-Storage-Limit schnell erreichen.`, { cause: error });
+ }
+
+ notify(id, 'saved');
+ return readPackage(id);
+ }
+
+ function remove(id) {
+ const target = storage();
+ const routeId = String(id);
+ target.removeItem(routeKey(routeId, 'gpx'));
+ target.removeItem(routeKey(routeId, 'json'));
+ writeIndex(readIndex().filter(item => item !== routeId));
+ notify(routeId, 'removed');
+ }
+
+ function listPackages() {
+ return readIndex().map(readPackage).filter(Boolean);
+ }
+
+ function routePoints(gpx) {
+ const document = parseXml(gpx);
+ const trackPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
+ const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
+ const selected = trackPoints.length ? trackPoints : routePoints;
+ return selected.map(node => {
+ const elevation = Number.parseFloat(textOfChild(node, 'ele'));
+ return {
+ lat: numberAttribute(node, 'lat'),
+ lon: numberAttribute(node, 'lon'),
+ elevation: Number.isFinite(elevation) ? elevation : null,
+ recordedAt: textOfChild(node, 'time') || null
+ };
+ }).filter(point => point.lat != null && point.lon != null);
+ }
+
+ function radians(value) {
+ return value * (Math.PI / 180);
+ }
+
+ function meters(left, right) {
+ const earthRadius = 6371008.8;
+ const latDelta = radians(right.lat - left.lat);
+ const lonDelta = radians(right.lon - left.lon);
+ const leftLat = radians(left.lat);
+ const rightLat = radians(right.lat);
+ const value = Math.sin(latDelta / 2) ** 2
+ + Math.cos(leftLat) * Math.cos(rightLat) * (Math.sin(lonDelta / 2) ** 2);
+ return earthRadius * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
+ }
+
+ function metrics(points) {
+ if (!points.length) {
+ return {
+ bounds: null,
+ center: null,
+ distanceM: 0,
+ elevationGainM: 0,
+ start: null
+ };
+ }
+ let distanceM = 0;
+ let elevationGainM = 0;
+ for (let index = 1; index < points.length; index += 1) {
+ distanceM += meters(points[index - 1], points[index]);
+ const previous = points[index - 1].elevation;
+ const current = points[index].elevation;
+ if (Number.isFinite(previous) && Number.isFinite(current) && current > previous) elevationGainM += current - previous;
+ }
+ const lats = points.map(point => point.lat);
+ const lons = points.map(point => point.lon);
+ const bounds = {
+ minLat: Math.min(...lats),
+ minLon: Math.min(...lons),
+ maxLat: Math.max(...lats),
+ maxLon: Math.max(...lons)
+ };
+ return {
+ bounds,
+ center: {
+ lat: (bounds.minLat + bounds.maxLat) / 2,
+ lon: (bounds.minLon + bounds.maxLon) / 2
+ },
+ distanceM,
+ elevationGainM,
+ start: { lat: points[0].lat, lon: points[0].lon }
+ };
+ }
+
+ function appPoi(poi) {
+ return {
+ id: Number(poi.id),
+ title: poi.title || 'Unbenannte Station',
+ description: poi.description || '',
+ lat: Number(poi.lat),
+ lon: Number(poi.lon),
+ triggerRadiusM: Number(poi.triggerRadiusM) || 25,
+ sequence: Number(poi.sequence) || 0,
+ audioUrl: poi.audio?.dataUrl || null,
+ images: (poi.images || []).map((image, index) => ({
+ id: image.id || `${poi.id}-${index}`,
+ caption: image.caption || '',
+ sequence: index,
+ url: image.dataUrl
+ })).filter(image => image.url)
+ };
+ }
+
+ function toAppRoute(routePackage) {
+ const pois = (routePackage.pois || []).map(appPoi).sort((left, right) => left.sequence - right.sequence);
+ const parsedPoints = routePoints(routePackage.gpx);
+ const points = parsedPoints.length
+ ? parsedPoints
+ : pois.map(poi => ({ lat: poi.lat, lon: poi.lon, elevation: null, recordedAt: null }));
+ const routeMetrics = metrics(points);
+ return {
+ id: String(routePackage.id),
+ source: 'local',
+ local: true,
+ name: routePackage.route?.name || 'Unbenannte Strecke',
+ description: routePackage.route?.description || '',
+ schoolName: 'Lokal gespeichert',
+ status: 'local',
+ activatedAt: null,
+ points,
+ pois,
+ pointCount: points.length,
+ gpxUrl: null,
+ createdAt: routePackage.route?.createdAt || null,
+ updatedAt: routePackage.route?.updatedAt || null,
+ ...routeMetrics
+ };
+ }
+
+ function listAppRoutes() {
+ return listPackages().map(toAppRoute);
+ }
+
+ function getAppRoute(id) {
+ const routePackage = readPackage(id);
+ if (!routePackage) throw new Error('Die lokale Strecke wurde nicht gefunden.');
+ return toAppRoute(routePackage);
+ }
+
+ function readFileAsText(file) {
+ if (!file) return Promise.resolve('');
+ if (typeof file.text === 'function') return file.text();
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true });
+ reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
+ reader.readAsText(file);
+ });
+ }
+
+ function fileToDataUrl(file) {
+ if (!file) return Promise.resolve(null);
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => resolve({
+ dataUrl: String(reader.result || ''),
+ name: file.name || 'Datei',
+ size: Number(file.size) || 0,
+ type: file.type || 'application/octet-stream'
+ }), { once: true });
+ reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
+ reader.readAsDataURL(file);
+ });
+ }
+
+ ns.LocalRoutes = Object.freeze({
+ STORAGE_PREFIX,
+ createPoiId,
+ createRouteId,
+ emptyGpx,
+ fileToDataUrl,
+ get: readPackage,
+ getAppRoute,
+ list: listPackages,
+ listAppRoutes,
+ parseWaypoints,
+ readFileAsText,
+ remove,
+ save: savePackage,
+ syncGpx,
+ toAppRoute
+ });
+}(globalThis.Wegwichtel));
diff --git a/src/routes/api.js b/src/routes/api.js
index a870562..c7e90fc 100644
--- a/src/routes/api.js
+++ b/src/routes/api.js
@@ -51,11 +51,13 @@ export function createApiRouter(db) {
});
api.get('/routes', (req, res) => {
+ const includeDeleted = req.query.includeDeleted === 'true';
res.json({ routes: listRoutes(db, {
lat: numberOrUndefined(req.query.lat),
lon: numberOrUndefined(req.query.lon),
radiusKm: numberOrUndefined(req.query.radiusKm),
- includeDeleted: req.query.includeDeleted === 'true'
+ includeDeleted,
+ publishedOnly: req.query.activatedOnly === 'true'
}) });
});
diff --git a/src/services/routes-service.js b/src/services/routes-service.js
index 04d9a9a..77a94fb 100644
--- a/src/services/routes-service.js
+++ b/src/services/routes-service.js
@@ -24,10 +24,8 @@ const UPDATE_METRICS_SQL = `UPDATE routes SET
function trimEdgeHyphens(value) {
let start = 0;
let end = value.length;
-
while (start < end && value[start] === '-') start += 1;
while (end > start && value[end - 1] === '-') end -= 1;
-
return value.slice(start, end);
}
@@ -45,7 +43,6 @@ const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio
function rowToRoute(row, includeDeleted = false) {
if (!row || (!includeDeleted && row.status !== 'active')) return null;
-
return {
id: row.id,
slug: row.slug,
@@ -53,6 +50,7 @@ function rowToRoute(row, includeDeleted = false) {
description: row.description,
schoolName: row.school_name,
status: row.status,
+ activatedAt: row.activated_at,
start: row.start_lat == null ? null : { lat: row.start_lat, lon: row.start_lon },
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
bounds: row.min_lat == null
@@ -101,19 +99,16 @@ function uniqueSlug(db, source) {
const base = slugify(source);
let candidate = base;
let suffix = 2;
-
while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(candidate)) {
candidate = `${base}-${suffix}`;
suffix += 1;
}
-
return candidate;
}
function cleanupFailedRoute(db, routeId, gpxFile) {
removeUpload(gpxFile);
if (!routeId) return;
-
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
@@ -131,13 +126,18 @@ export function listRoutes(db, {
lat,
lon,
radiusKm = config.defaultRouteRadiusKm,
- includeDeleted = false
+ includeDeleted = false,
+ publishedOnly = false
}) {
- const where = includeDeleted ? '' : "WHERE status = 'active'";
+ let where = '';
+ if (!includeDeleted) {
+ where = publishedOnly
+ ? "WHERE status = 'active' AND activated_at IS NOT NULL"
+ : "WHERE status = 'active'";
+ }
const rows = db.prepare(`SELECT * FROM routes ${where} ORDER BY name COLLATE NOCASE`).all();
const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
const maximumDistance = radiusKm * 1000;
-
return rows
.map(row => {
const route = rowToRoute(row, includeDeleted);
@@ -161,7 +161,6 @@ export function getRoute(db, id, includeDeleted = false) {
export function getRouteGpxFile(db, id) {
const route = db.prepare("SELECT id, slug, gpx_path FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
-
return {
absolutePath: resolveStoredFile(route.gpx_path),
filename: `${route.slug}.gpx`
@@ -170,13 +169,11 @@ export function getRouteGpxFile(db, id) {
export function createRoute(db, fields, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
-
let routeId;
try {
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const name = String(fields.name || parsed.name || '').trim();
if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.');
-
routeId = transaction(db, () => {
const slug = uniqueSlug(db, fields.slug || name);
const metrics = parsed.metrics;
@@ -205,7 +202,6 @@ export function createRoute(db, fields, gpxFile) {
insertRoutePoints(db, id, parsed.points);
return id;
});
-
ensureRouteDirectories(routeId);
const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx'));
db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
@@ -220,7 +216,6 @@ export function createRoute(db, fields, gpxFile) {
export function updateRoute(db, id, fields, gpxFile) {
const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
-
const parsed = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null;
transaction(db, () => {
db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP
@@ -230,7 +225,6 @@ export function updateRoute(db, id, fields, gpxFile) {
fields.schoolName ?? existing.school_name,
id
);
-
if (!parsed) return;
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
insertRoutePoints(db, id, parsed.points);
@@ -243,7 +237,6 @@ export function updateRoute(db, id, fields, gpxFile) {
export function appendRoute(db, id, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
-
const existing = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
@@ -251,7 +244,6 @@ export function appendRoute(db, id, gpxFile) {
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
-
transaction(db, () => {
insertRoutePoints(db, id, parsed.points, current.length);
updateMetrics(db, id, routeMetrics(allPoints));
@@ -265,7 +257,6 @@ export function listPois(db, routeId) {
const pois = db.prepare('SELECT * FROM pois WHERE route_id = ? ORDER BY sequence, id').all(routeId);
const images = db.prepare(`SELECT id, path, caption, sequence FROM poi_images
WHERE poi_id = ? ORDER BY sequence, id`);
-
return pois.map(poi => ({
id: poi.id,
routeId: poi.route_id,
@@ -301,7 +292,6 @@ export function createPoi(db, routeId, fields) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
}
-
const result = db.prepare(`INSERT INTO pois
(route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
routeId,
@@ -319,7 +309,6 @@ export function updatePoi(db, routeId, poiId, fields) {
const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id
WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId);
if (existing?.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
-
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
const radius = fields.triggerRadiusM == null
@@ -329,7 +318,6 @@ export function updatePoi(db, routeId, poiId, fields) {
if (![lat, lon, radius].every(Number.isFinite) || !Number.isInteger(sequence) || sequence < 0) {
throw new HttpError(400, 'POI-Koordinaten, Auslöseradius oder Reihenfolge sind ungültig.');
}
-
db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?`).run(
fields.title ?? existing.title,
@@ -347,7 +335,6 @@ export function deletePoi(db, routeId, poiId) {
const existing = db.prepare(`SELECT p.id, p.audio_path FROM pois p JOIN routes r ON r.id = p.route_id
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId);
if (!existing) throw new HttpError(404, 'POI nicht gefunden.');
-
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').all(poiId);
transaction(db, () => {
db.prepare('DELETE FROM poi_images WHERE poi_id = ?').run(poiId);
@@ -362,7 +349,6 @@ export function deletePoi(db, routeId, poiId) {
export function softDeleteRoute(db, id) {
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.');
-
const moved = softDeleteRouteDirectory(id);
try {
transaction(db, () => {
@@ -375,14 +361,12 @@ export function softDeleteRoute(db, id) {
fs.renameSync(moved.destination, routeDirectory(id));
throw error;
}
-
return rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), true);
}
export function restoreRoute(db, id) {
const route = db.prepare("SELECT trash_path FROM routes WHERE id = ? AND status = 'deleted'").get(id);
if (!route) throw new HttpError(404, 'Gelöschte Strecke nicht gefunden.');
-
const moved = restoreRouteDirectory(id, route.trash_path);
try {
transaction(db, () => {
@@ -395,6 +379,5 @@ export function restoreRoute(db, id) {
fs.renameSync(moved.destination, moved.source);
throw error;
}
-
return getRoute(db, id);
}
diff --git a/test/editor-database-schema.test.js b/test/editor-database-schema.test.js
index af2ef06..3672d95 100644
--- a/test/editor-database-schema.test.js
+++ b/test/editor-database-schema.test.js
@@ -9,58 +9,41 @@ async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
}
-function normalizedTokens(line) {
- return line
- .trim()
- .replaceAll(',', ' ')
- .replaceAll(';', ' ')
- .replaceAll('(', ' ')
- .replaceAll(')', ' ')
- .split(' ')
- .map(token => token.trim().toUpperCase())
- .filter(Boolean);
-}
-
-function routeTableLines(schema) {
- const lines = schema.split('\n');
- const start = lines.findIndex(line => normalizedTokens(line).join(' ') === 'CREATE TABLE IF NOT EXISTS ROUTES');
- assert.notEqual(start, -1, 'database/schema.sql must define the routes table');
-
- const end = lines.findIndex((line, index) => index > start && normalizedTokens(line).includes('STRICT'));
- assert.notEqual(end, -1, 'the routes table must be declared STRICT');
- return lines.slice(start, end + 1);
-}
-
-function hasActivatedAtTextDefaultNull(lines) {
- return lines.some(line => {
- const tokens = normalizedTokens(line);
- return tokens[0] === 'ACTIVATED_AT'
- && tokens[1] === 'TEXT'
- && tokens.includes('DEFAULT')
- && tokens.includes('NULL');
- });
-}
-
-function containsTokenSequence(sql, sequence) {
- const tokens = sql
- .split('\n')
- .flatMap(normalizedTokens);
-
- return tokens.some((_, index) => sequence.every((token, offset) => tokens[index + offset] === token));
-}
-
-test('initial database schema creates routes with a nullable activation timestamp', async () => {
+test('fresh routes schema stores timestamps as STRICT-compatible TEXT values', async () => {
const schema = await read('database/schema.sql');
- const migration = await read('database/migrations/20260901_add_route_activated_at.sql');
- const routes = routeTableLines(schema);
+ const routesTable = schema.match(/CREATE TABLE IF NOT EXISTS routes\s*\((?[\s\S]*?)\)\s*STRICT\s*;/i);
- assert.equal(hasActivatedAtTextDefaultNull(routes), true);
- assert.equal(containsTokenSequence(routes.join('\n'), ['ACTIVATED_AT', 'DATETIME']), false);
- assert.equal(containsTokenSequence(routes.join('\n'), ['ON', 'UPDATE', 'CURRENT_TIMESTAMP']), false);
- assert.equal(containsTokenSequence(schema, ['CREATE', 'INDEX', 'IF', 'NOT', 'EXISTS', 'IDX_ROUTES_ACTIVATION', 'ON', 'ROUTES']), true);
- assert.equal(containsTokenSequence(schema, ['CREATE', 'TRIGGER', 'IF', 'NOT', 'EXISTS', 'POIS_UPDATED_AT']), true);
- assert.equal(containsTokenSequence(migration, ['ALTER', 'TABLE', 'ROUTES', 'ADD', 'COLUMN', 'ACTIVATED_AT', 'TEXT']), true);
- assert.equal(containsTokenSequence(migration, ['CREATE', 'INDEX', 'IF', 'NOT', 'EXISTS', 'IDX_ROUTES_ACTIVATION', 'ON', 'ROUTES']), true);
- assert.equal(containsTokenSequence(migration, ['ACTIVATED_AT', 'DATETIME']), false);
- assert.equal(migration.includes('activated_at is NULL'), true);
+ assert.ok(routesTable?.groups?.body, 'routes must be a STRICT table');
+ const body = routesTable.groups.body;
+
+ assert.match(body, /created_at\s+TEXT\s+NOT NULL\s+DEFAULT\s+CURRENT_TIMESTAMP/i);
+ assert.match(body, /updated_at\s+TEXT\s+NOT NULL\s+DEFAULT\s+CURRENT_TIMESTAMP/i);
+ assert.match(body, /deleted_at\s+TEXT\s+DEFAULT\s+NULL/i);
+ assert.match(body, /activated_at\s+TEXT\s+DEFAULT\s+NULL/i);
+ assert.doesNotMatch(body, /activated_at\s+DATETIME/i);
+ assert.doesNotMatch(body, /ON\s+UPDATE\s+CURRENT_TIMESTAMP/i);
+});
+
+test('fresh schema owns timestamp triggers and activation index', async () => {
+ const schema = await read('database/schema.sql');
+
+ assert.match(schema, /CREATE TRIGGER IF NOT EXISTS routes_updated_at/i);
+ assert.match(schema, /AFTER UPDATE ON routes/i);
+ assert.match(schema, /SET updated_at\s*=\s*CURRENT_TIMESTAMP/i);
+ assert.match(schema, /CREATE TRIGGER IF NOT EXISTS pois_updated_at/i);
+ assert.match(schema, /AFTER UPDATE ON pois/i);
+ assert.match(schema, /CREATE INDEX IF NOT EXISTS idx_routes_activation\s+ON routes\s*\(\s*status\s*,\s*activated_at\s*\)/i);
+});
+
+test('public route listing can require activated routes without changing base service semantics', async () => {
+ const service = await read('src/services/routes-service.js');
+ const api = await read('src/routes/api.js');
+ const client = await read('public/js/api-client.js');
+
+ assert.match(service, /publishedOnly\s*=\s*false/);
+ assert.match(service, /activated_at\s+IS\s+NOT\s+NULL/i);
+ assert.match(service, /activatedAt:\s*row\.activated_at/);
+
+ assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/);
+ assert.match(client, /activatedOnly:\s*['"]true['"]/);
});
diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js
index 8cd8ff1..9427572 100644
--- a/test/editor-ui.test.js
+++ b/test/editor-ui.test.js
@@ -10,60 +10,40 @@ async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8');
}
-test('route editor entry point, dialog and forms are present', async () => {
+function tick() {
+ return new Promise(resolve => setTimeout(resolve, 0));
+}
+
+test('editor markup and source describe local draft creation instead of immediate publication', async () => {
const html = await read('public/index.html');
const css = await read('public/css/editor.css');
const ui = await read('public/js/editor-ui.js');
assert.match(html, /id="editor-button"/);
- assert.match(html, /aria-label="Streckeneditor öffnen"/);
- assert.match(html, /aria-haspopup="dialog"/);
- assert.match(html, /aria-controls="editor-dialog"/);
assert.match(html, /