diff --git a/examples/presentation-route.gpx b/examples/presentation-route.gpx
new file mode 100644
index 0000000..cf570b7
--- /dev/null
+++ b/examples/presentation-route.gpx
@@ -0,0 +1,29 @@
+
+
+
+ Wegwichtel Demo – Rundweg
+ Beispielstrecke für Präsentations- und Funktionstests. Keine reale Wegführung.
+
+
+ Wegwichtel Demo – Rundweg
+
+ 328
+ 330
+ 332
+ 334
+ 337
+ 339
+ 338
+ 336
+ 333
+ 331
+ 329
+ 327
+ 327
+ 328
+
+
+
diff --git a/examples/presentation-route.json b/examples/presentation-route.json
new file mode 100644
index 0000000..4f44e92
--- /dev/null
+++ b/examples/presentation-route.json
@@ -0,0 +1,38 @@
+{
+ "name": "Wegwichtel Demo – Rundweg",
+ "description": "Beispielstrecke für Präsentations- und Funktionstests. Keine reale Wegführung.",
+ "pois": [
+ {
+ "title": "Demo-Start",
+ "description": "Hier beginnt die Präsentationsstrecke.",
+ "lat": 50.57988,
+ "lon": 10.43142,
+ "triggerRadiusM": 25,
+ "sequence": 0
+ },
+ {
+ "title": "Wiesenblick",
+ "description": "Ein Beispiel-Trackpunkt für Texte, Bilder und Audio.",
+ "lat": 50.58109,
+ "lon": 10.43384,
+ "triggerRadiusM": 25,
+ "sequence": 1
+ },
+ {
+ "title": "Waldrand",
+ "description": "Zweiter Beispiel-Trackpunkt der Demo-Strecke.",
+ "lat": 50.58082,
+ "lon": 10.43546,
+ "triggerRadiusM": 25,
+ "sequence": 2
+ },
+ {
+ "title": "Demo-Ziel",
+ "description": "Hier endet die Präsentationsstrecke.",
+ "lat": 50.57972,
+ "lon": 10.43231,
+ "triggerRadiusM": 25,
+ "sequence": 3
+ }
+ ]
+}
diff --git a/package.json b/package.json
index 1e2a59a..96524f5 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"file-type": "22.0.1",
"jquery": "4.0.0",
"jquery-ui": "1.14.2",
- "multer": "^2.4.0"
+ "multer": "2.2.0"
},
"devDependencies": {
"c8": "11.0.0"
diff --git a/public/css/bootstrap.css b/public/css/bootstrap.css
index 2681e39..1b3d067 100644
--- a/public/css/bootstrap.css
+++ b/public/css/bootstrap.css
@@ -1,14 +1,16 @@
:root {
--bootstrap-bg: #f3f7ee;
--bootstrap-card-bg: #ffffff;
- --bootstrap-text: #1c2b20;
- --bootstrap-muted: #53605a;
- --bootstrap-accent: #315d3a;
- --bootstrap-accent-strong: #25462c;
- --bootstrap-border: #d6dfd3;
- --bootstrap-error: #8b1e1e;
- --bootstrap-shadow: 0 1rem 3rem rgb(29 52 36 / 18%);
+ --bootstrap-text: #10291b;
+ --bootstrap-muted: #67726c;
+ --bootstrap-accent: #286438;
+ --bootstrap-accent-strong: #194d2b;
+ --bootstrap-border: #dce6dc;
+ --bootstrap-error: #a32020;
+ --bootstrap-shadow: 0 1.2rem 3.6rem rgb(29 52 36 / 16%);
+ --bootstrap-fade-duration: 180ms;
}
+
#bootstrap-screen {
position: fixed;
inset: 0;
@@ -18,7 +20,7 @@
justify-items: center;
min-height: 100svh;
box-sizing: border-box;
- padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
+ padding: max(5.25rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior-y: contain;
@@ -26,127 +28,315 @@
-webkit-overflow-scrolling: touch;
color: var(--bootstrap-text);
background:
- radial-gradient(circle at 20% 10%, rgb(135 185 112 / 32%), transparent 34rem),
- radial-gradient(circle at 85% 90%, rgb(230 198 116 / 25%), transparent 28rem),
- var(--bootstrap-bg);
+ radial-gradient(circle at 12% 6%, rgb(126 183 101 / 24%), transparent 27rem),
+ radial-gradient(circle at 88% 94%, rgb(235 196 104 / 18%), transparent 25rem),
+ linear-gradient(180deg, #f7faf4 0%, var(--bootstrap-bg) 100%);
}
+
#bootstrap-screen[hidden] {
display: none;
}
#bootstrap-screen > section {
- width: min(100%, 34rem);
+ width: min(100%, 42rem);
box-sizing: border-box;
- padding: clamp(1.25rem, 5vw, 2rem);
- border: 1px solid var(--bootstrap-border);
- border-radius: 1.5rem;
+ padding: clamp(1.1rem, 4vw, 1.8rem);
+ border: 1px solid rgb(214 224 214 / 80%);
+ border-radius: clamp(1.4rem, 5vw, 2rem);
box-shadow: var(--bootstrap-shadow);
- background: var(--bootstrap-card-bg);
+ background: rgb(255 255 255 / 96%);
}
-#bootstrap-screen [aria-hidden="true"] {
- display: inline-block;
- margin-bottom: .25rem;
- font-size: 2rem;
+.bootstrap-brand {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1rem;
}
+
+.bootstrap-logo {
+ width: clamp(4.25rem, 15vw, 5.75rem);
+ height: auto;
+ filter: drop-shadow(0 .35rem .6rem rgb(49 93 58 / 12%));
+}
+
#bootstrap-screen h1 {
- margin: 0 0 .5rem;
- font-size: clamp(1.8rem, 8vw, 2.5rem);
- line-height: 1.05;
+ margin: 0 0 .3rem;
+ color: #0d2b1b;
+ font-size: clamp(2rem, 8vw, 3rem);
+ line-height: 1;
+ letter-spacing: -.035em;
}
-#bootstrap-screen p {
- margin: .65rem 0;
+#bootstrap-screen .bootstrap-brand p {
+ margin: 0;
color: var(--bootstrap-muted);
+ font-size: clamp(.98rem, 3.8vw, 1.18rem);
+ line-height: 1.35;
+}
+
+.bootstrap-hero {
+ overflow: hidden;
+ margin: .35rem 0 1rem;
+ border-radius: 1.5rem;
+ background: #eef7f1;
+}
+
+.bootstrap-hero svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ max-height: 16rem;
+}
+
+.bootstrap-action-slot {
+ display: grid;
+ margin: 1rem 0;
+}
+
+.bootstrap-action-slot > * {
+ grid-area: 1 / 1;
+}
+
+.bootstrap-action-status,
+#bootstrap-enter.ui-button,
+.bootstrap-enter {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: 4rem;
+ box-sizing: border-box;
+ margin: 0;
+ padding: .8rem 1.25rem;
+ border: 0;
+ border-radius: 999px;
+ font: inherit;
+ font-size: clamp(1.05rem, 4.4vw, 1.28rem);
+ font-weight: 750;
+ letter-spacing: .005em;
+}
+
+.bootstrap-action-status {
+ gap: .75rem;
+ color: #eff8f0;
+ background: linear-gradient(135deg, #387347, var(--bootstrap-accent));
+ box-shadow: 0 .45rem 1.1rem rgb(40 100 56 / 18%);
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity var(--bootstrap-fade-duration) ease, transform var(--bootstrap-fade-duration) ease;
+}
+
+.bootstrap-action-status.is-leaving {
+ opacity: 0;
+ transform: translateY(-.2rem);
+ pointer-events: none;
+}
+
+.bootstrap-loading-dot {
+ flex: 0 0 auto;
+ width: .65rem;
+ height: .65rem;
+ border-radius: 50%;
+ background: #d9f0de;
+ box-shadow: 0 0 0 0 rgb(217 240 222 / 50%);
+ animation: bootstrap-pulse 1.35s ease-out infinite;
+}
+
+.bootstrap-status-text {
+ opacity: 1;
+ transition: opacity var(--bootstrap-fade-duration) ease;
+}
+
+.bootstrap-status-text.is-fading {
+ opacity: 0;
+}
+
+#bootstrap-enter.ui-button,
+.bootstrap-enter {
+ gap: .75rem;
+ color: #fff;
+ background: linear-gradient(135deg, #2d743d, var(--bootstrap-accent-strong));
+ box-shadow: 0 .55rem 1.25rem rgb(25 77 43 / 24%);
+ cursor: pointer;
+ opacity: 0;
+ transform: translateY(.2rem);
+ transition: opacity var(--bootstrap-fade-duration) ease, transform var(--bootstrap-fade-duration) ease, background-color .15s ease;
+}
+
+#bootstrap-enter.is-visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+#bootstrap-enter svg {
+ width: 1.5rem;
+ height: 1.5rem;
+ margin-left: auto;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+#bootstrap-enter:hover,
+#bootstrap-enter:focus-visible {
+ background: #173f24;
+}
+
+.bootstrap-features {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ margin: 1rem 0;
+ padding: .8rem .4rem;
+ border: 1px solid #e2ebe2;
+ border-radius: 1.35rem;
+ background: linear-gradient(135deg, #f4f9f4, #fbfcf8);
+}
+
+.bootstrap-features > div {
+ display: grid;
+ place-items: center;
+ gap: .4rem;
+ min-width: 0;
+ padding: .3rem .45rem;
+ text-align: center;
+}
+
+.bootstrap-features > div + div {
+ border-left: 1px solid #dce6dc;
+}
+
+.bootstrap-features svg {
+ width: 2rem;
+ height: 2rem;
+ fill: none;
+ stroke: var(--bootstrap-accent);
+ stroke-width: 2.2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.bootstrap-features > div:nth-child(2) svg {
+ stroke: #7351b4;
+}
+
+.bootstrap-features > div:nth-child(3) svg {
+ fill: #e96c2a;
+ stroke: #e96c2a;
+}
+
+.bootstrap-features span {
+ color: #24402c;
+ font-size: clamp(.78rem, 3vw, .95rem);
+ font-weight: 650;
+ line-height: 1.2;
}
#bootstrap-screen .privacy-summary {
display: grid;
- gap: .75rem;
- margin: 1rem 0;
+ gap: .65rem;
+ margin: .85rem 0 0;
padding: .85rem;
border: 1px solid var(--bootstrap-border);
- border-radius: 1rem;
+ border-radius: 1.15rem;
background: #f9fbf6;
}
#bootstrap-screen .privacy-summary > p {
margin: 0;
+ color: var(--bootstrap-muted);
+ font-size: .92rem;
+ line-height: 1.4;
}
+
#bootstrap-screen .privacy-summary > .switch-row.switch-disabled {
display: none;
}
-#bootstrap-screen progress {
- width: 100%;
- height: .9rem;
- overflow: hidden;
- border: 0;
- border-radius: 999px;
- background: #dce7d8;
-}
-
-#bootstrap-screen progress::-webkit-progress-bar {
- border-radius: 999px;
- background: #dce7d8;
-}
-
-#bootstrap-screen progress::-webkit-progress-value {
- border-radius: 999px;
- background: var(--bootstrap-accent);
-}
-#bootstrap-screen progress::-moz-progress-bar {
- border-radius: 999px;
- background: var(--bootstrap-accent);
-}
-
-#bootstrap-steps {
- display: grid;
- gap: .35rem;
- margin: 1rem 0;
- padding-left: 1.4rem;
+.storage-cleanup-status {
+ min-height: 1.2rem;
+ margin: .75rem 0 0;
color: var(--bootstrap-muted);
+ font-size: .86rem;
+ text-align: center;
}
-#bootstrap-steps li[data-state="done"] {
- color: var(--bootstrap-accent-strong);
-}
-
-#bootstrap-steps li[data-state="active"] {
- font-weight: 700;
- color: var(--bootstrap-text);
-}
-
-#bootstrap-error {
+.bootstrap-error {
+ margin: .65rem 0 0;
+ padding: .75rem .9rem;
+ border-radius: .9rem;
color: var(--bootstrap-error);
+ background: #fff0f0;
font-weight: 700;
}
-#bootstrap-retry,
-#bootstrap-enter {
+
+.bootstrap-retry,
+#bootstrap-retry.ui-button {
width: 100%;
min-height: 3rem;
- margin-top: .75rem;
- border: 0;
+ box-sizing: border-box;
+ margin-top: .65rem;
+ border: 1px solid #d8abab;
border-radius: 999px;
- color: #ffffff;
- background: var(--bootstrap-accent);
+ color: #861d1d;
+ background: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
-#bootstrap-retry:hover,
-#bootstrap-enter:hover,
-#bootstrap-retry:focus-visible,
-#bootstrap-enter:focus-visible {
- background: var(--bootstrap-accent-strong);
+
+.bootstrap-ready,
+.bootstrap-state-model {
+ display: none !important;
}
-.bootstrap-ready {
- font-weight: 700;
- color: var(--bootstrap-accent-strong);
+@keyframes bootstrap-pulse {
+ 0% { box-shadow: 0 0 0 0 rgb(217 240 222 / 55%); }
+ 65%, 100% { box-shadow: 0 0 0 .7rem rgb(217 240 222 / 0%); }
}
-.storage-cleanup-status {
- min-height: 1.25rem;
- font-size: .95rem;
+@media (max-width: 34rem) {
+ #bootstrap-screen {
+ padding-right: max(.75rem, env(safe-area-inset-right));
+ padding-left: max(.75rem, env(safe-area-inset-left));
+ }
+
+ #bootstrap-screen > section {
+ padding: 1rem;
+ }
+
+ .bootstrap-brand {
+ gap: .75rem;
+ }
+
+ .bootstrap-hero svg {
+ max-height: 12.5rem;
+ }
+
+ .bootstrap-features {
+ padding: .65rem .25rem;
+ }
+}
+
+@media (max-height: 700px) {
+ .bootstrap-hero svg {
+ max-height: 10rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bootstrap-loading-dot {
+ animation: none;
+ }
+
+ .bootstrap-status-text,
+ .bootstrap-action-status,
+ #bootstrap-enter.ui-button,
+ .bootstrap-enter {
+ transition: none;
+ }
}
diff --git a/public/index.html b/public/index.html
index e05a4cb..1233a29 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,12 +22,77 @@
-
- 🧭
- Wegwichtel
- Der Wegwichtel bereitet Routen und Medien vor.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alles ist bereit.
+
+
+
+
+
+
Spannende
Geschichten
+
+
+
+
Kindgerechte
Erlebnisse
+
+
+
-
Cookies: Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.
+
Datenschutzfreundlich: Keine Werbung, kein Tracking und derzeit keine Cookies.
-
-
+
+
+
- jQuery laden
- jQuery UI laden
- Anwendungsmodule laden
@@ -55,10 +121,6 @@
- Routen laden
- Standort ermitteln
- Alles ist bereit. Tippe oder klicke auf diese Startseite, um die Anwendung zu öffnen.
-
-
-
diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js
index be33404..244d4d7 100644
--- a/public/js/bootstrap-loader.js
+++ b/public/js/bootstrap-loader.js
@@ -3,6 +3,14 @@
const versions = Object.freeze({ app: '0.12.14', jquery: '4.0.0', jqueryUi: '1.14.2' });
const steps = ['jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location'];
+ const stepLabels = Object.freeze({
+ jquery: 'jQuery laden …',
+ 'jquery-ui': 'jQuery UI laden …',
+ modules: 'Anwendungsmodule laden …',
+ server: 'Server prüfen …',
+ routes: 'Routen laden …',
+ location: 'Standort ermitteln …'
+ });
const screen = document.getElementById('bootstrap-screen');
const progress = document.getElementById('bootstrap-progress');
const errorBox = document.getElementById('bootstrap-error');
@@ -10,16 +18,54 @@
const enter = document.getElementById('bootstrap-enter');
const readyText = document.getElementById('bootstrap-ready');
const cleanupStatus = document.getElementById('storage-cleanup-status');
+ const actionStatus = document.getElementById('bootstrap-action-status');
+ const statusText = document.getElementById('bootstrap-status-text');
let ready = false;
+ let statusTransition = 0;
const versioned = path => `${path}${path.includes('?') ? '&' : '?'}v=${encodeURIComponent(versions.app)}`;
+ function setStatusText(text, immediate = false) {
+ if (!statusText) return;
+ statusTransition += 1;
+ const transition = statusTransition;
+ if (immediate) {
+ statusText.textContent = text;
+ statusText.classList.remove('is-fading');
+ return;
+ }
+ statusText.classList.add('is-fading');
+ globalThis.setTimeout(() => {
+ if (transition !== statusTransition) return;
+ statusText.textContent = text;
+ statusText.classList.remove('is-fading');
+ }, 180);
+ }
+
+ function nextPendingStep() {
+ return steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done'));
+ }
+
+ function announceStep(step) {
+ if (stepLabels[step]) setStatusText(stepLabels[step]);
+ }
+
function mark(step, state) {
const item = document.querySelector(`[data-step="${step}"]`);
if (!item) return;
item.classList.remove('done', 'failed');
item.classList.add(state);
+ item.dataset.state = state;
progress.value = document.querySelectorAll('#bootstrap-steps .done').length;
+
+ if (state === 'failed') {
+ setStatusText(`Fehler bei: ${stepLabels[step] || step}`);
+ return;
+ }
+ if (state === 'done') {
+ const next = nextPendingStep();
+ if (next) announceStep(next);
+ }
}
function loadScript(src, test) {
@@ -78,7 +124,14 @@
screen.setAttribute('aria-busy', 'false');
screen.classList.add('ready');
readyText.hidden = false;
+
+ setStatusText('Vorbereitung abgeschlossen.');
+ actionStatus?.classList.add('is-leaving');
enter.hidden = false;
+ globalThis.requestAnimationFrame(() => enter.classList.add('is-visible'));
+ globalThis.setTimeout(() => {
+ if (actionStatus) actionStatus.hidden = true;
+ }, 220);
enter.focus();
}
@@ -88,11 +141,18 @@
screen.classList.remove('ready');
readyText.hidden = true;
enter.hidden = true;
+ enter.classList.remove('is-visible');
errorBox.hidden = true;
retry.hidden = true;
+ if (actionStatus) {
+ actionStatus.hidden = false;
+ actionStatus.classList.remove('is-leaving');
+ }
try {
+ setStatusText('Lokalen Speicher prüfen …', true);
await cleanupOldProgress();
+ announceStep('jquery');
await loadScript(
`vendor/jquery/jquery-${versions.jquery}.min.js`,
@@ -116,7 +176,7 @@
showReadyState();
} catch (error) {
console.error(error);
- const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done'));
+ const current = nextPendingStep();
if (current) mark(current, 'failed');
errorBox.textContent = error.message;
errorBox.hidden = false;
diff --git a/scripts/upload-presentation-route.sh b/scripts/upload-presentation-route.sh
new file mode 100644
index 0000000..4289d77
--- /dev/null
+++ b/scripts/upload-presentation-route.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BASE_URL="${1:-https://wegwichtel.de/api}"
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+GPX_FILE="$ROOT_DIR/examples/presentation-route.gpx"
+JSON_FILE="$ROOT_DIR/examples/presentation-route.json"
+
+for command in curl python3; do
+ command -v "$command" >/dev/null 2>&1 || {
+ echo "Fehler: $command wird benötigt." >&2
+ exit 1
+ }
+done
+
+read_json() {
+ python3 - "$JSON_FILE" "$1" <<'PY'
+import json
+import sys
+with open(sys.argv[1], encoding='utf-8') as handle:
+ data = json.load(handle)
+value = data
+for part in sys.argv[2].split('.'):
+ value = value[int(part)] if part.isdigit() else value[part]
+print(value)
+PY
+}
+
+NAME="$(read_json name)"
+DESCRIPTION="$(read_json description)"
+
+echo "Lege Beispielstrecke über $BASE_URL an …"
+ROUTE_RESPONSE="$(curl --fail --silent --show-error \
+ -X POST "$BASE_URL/routes" \
+ -F "name=$NAME" \
+ -F "description=$DESCRIPTION" \
+ -F "gpx=@$GPX_FILE;type=application/gpx+xml")"
+
+ROUTE_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$ROUTE_RESPONSE")"
+
+rollback() {
+ echo "Upload fehlgeschlagen. Entferne die unvollständige Beispielstrecke $ROUTE_ID …" >&2
+ curl --fail --silent --show-error -X DELETE "$BASE_URL/routes/$ROUTE_ID" >/dev/null || true
+}
+trap rollback ERR
+
+POI_COUNT="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1], encoding="utf-8"))["pois"]))' "$JSON_FILE")"
+for ((index=0; index/dev/null
+ echo " Trackpunkt $((index + 1))/$POI_COUNT: $TITLE"
+done
+
+trap - ERR
+cat < {
+ const html = await read('public/index.html');
+
+ assert.match(html, /class="bootstrap-brand"/);
+ assert.match(html, /class="bootstrap-hero"/);
+ assert.match(html, /class="bootstrap-features"/);
+ assert.match(html, /id="bootstrap-action-status"/);
+ assert.match(html, /id="bootstrap-status-text"/);
+ assert.match(html, /id="bootstrap-enter"[^>]*hidden/);
+ assert.match(html, />Los geht’s);
+ assert.match(html, /id="bootstrap-steps"[^>]*hidden/);
+ assert.match(html, /id="bootstrap-progress"[^>]*hidden/);
+});
+
+test('bootstrap status text fades between loading steps and is replaced by the start button', async () => {
+ const css = await read('public/css/bootstrap.css');
+ const loader = await read('public/js/bootstrap-loader.js');
+
+ const statusTextRule = css.match(/\.bootstrap-status-text\s*\{([\s\S]*?)\}/)?.[1] ?? '';
+ const fadingRule = css.match(/\.bootstrap-status-text\.is-fading\s*\{([\s\S]*?)\}/)?.[1] ?? '';
+
+ assert.match(statusTextRule, /transition:\s*opacity/);
+ assert.match(fadingRule, /opacity:\s*0/);
+ assert.match(loader, /statusText\.classList\.add\('is-fading'\)/);
+ assert.match(loader, /statusText\.classList\.remove\('is-fading'\)/);
+ assert.match(loader, /server:\s*'Server prüfen …'/);
+ assert.match(loader, /routes:\s*'Routen laden …'/);
+ assert.match(loader, /location:\s*'Standort ermitteln …'/);
+ assert.match(loader, /actionStatus\?\.classList\.add\('is-leaving'\)/);
+ assert.match(loader, /enter\.classList\.add\('is-visible'\)/);
+});
diff --git a/test/presentation-route.test.js b/test/presentation-route.test.js
new file mode 100644
index 0000000..5530a30
--- /dev/null
+++ b/test/presentation-route.test.js
@@ -0,0 +1,22 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import test from 'node:test';
+
+const root = path.resolve(import.meta.dirname, '..');
+
+async function read(relativePath) {
+ return fs.readFile(path.join(root, relativePath), 'utf8');
+}
+
+test('presentation example uses real GPX track points and the existing upload API', async () => {
+ const gpx = await read('examples/presentation-route.gpx');
+ const metadata = JSON.parse(await read('examples/presentation-route.json'));
+ const script = await read('scripts/upload-presentation-route.sh');
+
+ assert.ok((gpx.match(/= 8);
+ assert.doesNotMatch(gpx, /= 3);
+ assert.match(script, /POST "\$BASE_URL\/routes"/);
+ assert.match(script, /POST "\$BASE_URL\/routes\/\$ROUTE_ID\/pois"/);
+});