Sprint 14: Map-Fixes, City-Prewarm, Dog-Animation, Scan-Flash

Karte:
- Frankfurt-Fallback (Zoom 10→14 flyTo) mit _frankfurtTimer-Cancel
  wenn echter Standort eintrifft
- OSM-Tile-Fetch parallelisiert (asyncio.Semaphore(3))
- Bounds-Fix: invalidateSize() + pad(0.15) vor getBounds()
- map-pin-slash Icon für gesperrten Standort
- Scan-Done-Flash: Statusbar-Pill grün bei 100%
- Schnüffelhund: outer div (by-wander X) + inner SVG (by-sniff Y)
  für natürlichere zweiachsige Bewegung

Backend:
- City-Prewarm-Job: ~70 deutsche Großstädte beim Start (+90s) und
  wöchentlich (So 01:00), Fortschritts-Mails alle 5h an ADMIN_EMAIL
- ADMIN_EMAIL Env-Var in .env.example dokumentiert

Bugfixes:
- Profil-Edit: /api/profile → /profile (doppelter Prefix)
- Friends: Mobile-Portrait-Layout (flex-wrap, overflow-x:hidden)
- Trainingspläne: Pills text-wrap (flex + white-space:normal)
This commit is contained in:
rene 2026-04-17 14:06:10 +02:00
parent cd3f118113
commit 6fcf841594
10 changed files with 340 additions and 32 deletions

View file

@ -36,7 +36,7 @@ window.Page_friends = (() => {
const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`;
_container.innerHTML = `
<div style="max-width:520px;margin:0 auto;padding:var(--space-4)">
<div style="max-width:520px;margin:0 auto;padding:var(--space-4);overflow-x:hidden">
<!-- Mein Freundes-Link -->
<div class="card" style="margin-bottom:var(--space-5);padding:var(--space-4)">
@ -186,18 +186,20 @@ window.Page_friends = (() => {
${list.map(r => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">
<div style="flex:1;min-width:120px">
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(r.requester_name)}
</div>
${_dogPills(r.dogs, 2)}
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<div style="display:flex;gap:var(--space-2);margin-left:auto">
<button class="btn btn-primary btn-sm"
onclick="Page_friends._accept(${r.id})" title="Annehmen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
Annehmen
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._decline(${r.id})" title="Ablehnen">
@ -426,7 +428,7 @@ window.Page_friends = (() => {
</div>`);
}
if (profile.social_link) {
parts.push(`<div style="font-size:var(--text-xs)">
parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
</div>`);
@ -508,10 +510,9 @@ window.Page_friends = (() => {
</div>`
: ''}
</div>
<button class="btn btn-primary btn-sm fr-add-btn"
<button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden"
data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Anfrage
</button>
</div>
`).join('')}

View file

@ -121,6 +121,8 @@ window.Page_map = (() => {
let _overpassTimer = null;
let _overpassActive = false;
let _ringClosing = false;
let _frankfurtTimer = null;
// ----------------------------------------------------------
// INIT
@ -133,11 +135,23 @@ window.Page_map = (() => {
// Alle-Button Initialzustand
const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder');
document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit);
try { _userPos = await API.getLocation(); } catch {}
await _loadLeaflet();
_initMap();
_initMap(); // sofort mit Deutschland-Mitte starten
_startLocationTracking();
_loadAll();
// Standort im Hintergrund holen — bei Erfolg zur Position fliegen
API.getLocation().then(pos => {
_userPos = pos;
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
}).catch(() => {
const btn = document.getElementById('map-locate-btn');
if (btn) {
btn.title = 'Standort nicht verfügbar';
btn.style.opacity = '0.55';
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin-slash"></use></svg>';
}
});
}
function refresh() { _loadAll(); }
@ -302,11 +316,15 @@ window.Page_map = (() => {
const el = document.getElementById('central-map');
if (!el || !window.L || _map) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
const zoom = _userPos ? 14 : 6;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [50.1109, 8.6821]; // Frankfurt
const zoom = _userPos ? 14 : 10;
_map = L.map('central-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
if (!_userPos) {
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
}
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
setTimeout(() => _map.invalidateSize(), 100);
@ -403,9 +421,89 @@ window.Page_map = (() => {
}
function _setOsmStatus(text, pct = null) {
const el = document.getElementById('map-osm-status');
const el = document.getElementById('map-osm-status');
const statusbar = document.getElementById('map-statusbar');
if (el) el.textContent = text;
_updateScanRing(text ? pct : null);
_updateScanDog(text ? pct : null);
if (pct === 100 && statusbar) {
statusbar.classList.add('scan-done');
setTimeout(() => statusbar.classList.remove('scan-done'), 2200);
}
}
function _injectDogStyles() {
if (document.getElementById('by-dog-style')) return;
const s = document.createElement('style');
s.id = 'by-dog-style';
s.textContent = [
'@keyframes by-sniff{0%,100%{transform:translateY(0) rotate(0deg)}30%{transform:translateY(2.5px) rotate(-1.5deg)}70%{transform:translateY(1px) rotate(1deg)}}',
'@keyframes by-wander{0%,100%{transform:translateX(0)}20%{transform:translateX(-7px)}45%{transform:translateX(5px)}68%{transform:translateX(-5px)}85%{transform:translateX(7px)}}',
'@keyframes by-wag{0%,100%{transform:rotate(-22deg)}50%{transform:rotate(22deg)}}',
'#map-scan-dog{animation:by-wander 1.75s ease-in-out infinite;transition:opacity .5s ease;color:#C4843A;position:absolute;pointer-events:none;z-index:1003;width:42px;height:32px}',
'#map-scan-dog svg{display:block;animation:by-sniff .42s ease-in-out infinite}',
'#map-scan-dog .by-tail{transform-box:fill-box;transform-origin:0% 100%;animation:by-wag .32s ease-in-out infinite}',
'#map-statusbar{transition:background .35s ease,color .35s ease,border-color .35s ease}',
'#map-statusbar.scan-done{background:#22C55E!important;color:#fff!important;border-color:#16A34A!important}',
].join('');
document.head.appendChild(s);
}
function _updateScanDog(pct) {
_injectDogStyles();
const statusbar = document.getElementById('map-statusbar');
if (!statusbar) return;
const mapEl = statusbar.closest('.map-main') || statusbar.parentElement;
if (!mapEl) return;
let dog = document.getElementById('map-scan-dog');
if (pct === null) {
if (_ringClosing) return;
if (dog) { dog.style.opacity = '0'; setTimeout(() => dog?.remove(), 550); }
return;
}
if (!dog) {
dog = document.createElement('div');
dog.id = 'map-scan-dog';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '42');
svg.setAttribute('height', '32');
svg.setAttribute('viewBox', '0 0 54 40');
svg.innerHTML = `
<ellipse cx="33" cy="22" rx="14" ry="8" fill="currentColor"/>
<ellipse cx="16" cy="20" rx="6" ry="7" fill="currentColor"/>
<ellipse cx="8" cy="27" rx="7" ry="6" fill="currentColor"/>
<ellipse cx="14" cy="16" rx="3" ry="5" fill="currentColor" transform="rotate(15,14,16)" opacity=".85"/>
<ellipse cx="3" cy="30" rx="3.5" ry="2.5" fill="currentColor"/>
<ellipse cx="1.5" cy="29" rx="2" ry="1.5" fill="#7a4f2a"/>
<circle cx="9" cy="24" r="1.3" fill="white" opacity=".9"/>
<rect x="22" y="28" width="3" height="10" rx="1.5" fill="currentColor"/>
<rect x="29" y="29" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="37" y="28" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="42" y="27" width="3" height="9" rx="1.5" fill="currentColor"/>
<g class="by-tail" transform="translate(47,17)">
<path d="M0,0 Q6,-10 4,-18" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round"/>
</g>
`;
dog.appendChild(svg);
mapEl.appendChild(dog);
}
const sr = statusbar.getBoundingClientRect();
const mr = mapEl.getBoundingClientRect();
dog.style.left = (sr.left - mr.left + sr.width - 36) + 'px';
dog.style.top = (sr.top - mr.top - 35) + 'px';
dog.style.opacity = '1';
if (pct >= 100) {
setTimeout(() => {
const d = document.getElementById('map-scan-dog');
if (d) { d.style.opacity = '0'; setTimeout(() => d?.remove(), 550); }
}, 500);
}
}
function _updateScanRing(pct) {
@ -418,6 +516,7 @@ window.Page_map = (() => {
// Ring ausblenden / entfernen
if (pct === null) {
if (_ringClosing) return;
if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); }
statusbar.style.border = '';
return;
@ -448,12 +547,13 @@ window.Page_map = (() => {
const p = 2; // Abstand zur inneren Kante
// Umfang der Pill: gerades Stück + zwei Halbkreise
const perim = 2 * (w - h) + Math.PI * h;
// Stroke beginnt oben-links und läuft im Uhrzeigersinn
// Um bei 12 Uhr zu starten: Offset um das linke Halbkreis-Viertel + halbe Geraden verschieben
const startShift = (w - h) / 2 + (Math.PI * h) / 4;
const progress = Math.min(100, Math.max(0, pct));
const dashOffset = perim * (1 - progress / 100) + startShift;
const perim = 2 * (w - h) + Math.PI * h;
// Natürlicher SVG-Start: linkes Ende der oberen Geraden
// 12-Uhr-Position: Mitte der oberen Geraden → Abstand = (w-h)/2
// dashoffset = perim - S verschiebt den Dash-Start genau dorthin
const S = (w - h) / 2;
const progress = Math.min(100, Math.max(0, pct));
const progressLen = progress * perim / 100;
svg.style.left = (sr.left - mr.left - p) + 'px';
svg.style.top = (sr.top - mr.top - p) + 'px';
@ -468,18 +568,19 @@ window.Page_map = (() => {
rect.setAttribute('height', String(h));
rect.setAttribute('rx', String(r));
rect.setAttribute('ry', String(r));
rect.setAttribute('stroke-dasharray', perim.toFixed(2));
rect.setAttribute('stroke-dashoffset', dashOffset.toFixed(2));
rect.setAttribute('stroke-dasharray', `${progressLen.toFixed(2)} ${(perim - progressLen).toFixed(2)}`);
rect.setAttribute('stroke-dashoffset', (perim - S).toFixed(2));
// Original-Rahmen verstecken während Ring aktiv ist
statusbar.style.border = 'none';
if (progress >= 100) {
_ringClosing = true;
setTimeout(() => {
const s = document.getElementById('map-scan-ring');
if (s) s.style.opacity = '0';
statusbar.style.border = '';
setTimeout(() => s?.remove(), 600);
setTimeout(() => { s?.remove(); _ringClosing = false; }, 600);
}, 500);
}
}
@ -517,7 +618,8 @@ window.Page_map = (() => {
}
_overpassActive = true;
const b = _map.getBounds();
_map.invalidateSize();
const b = _map.getBounds().pad(0.15);
const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
// Welche Layer bei diesem Zoom geladen werden

View file

@ -246,7 +246,7 @@ window.Page_settings = (() => {
try {
const fd = new FormData();
fd.append('file', file);
const res = await API.post('/api/profile/avatar', fd);
const res = await API.post('/profile/avatar', fd);
_appState.user.avatar_url = res.avatar_url;
UI.toast.success('Avatar aktualisiert.');
_render();
@ -326,7 +326,7 @@ window.Page_settings = (() => {
const btn = document.querySelector('[form="profile-form"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const updated = await API.patch('/api/profile', {
const updated = await API.patch('/profile', {
bio: fd.bio || '',
wohnort: fd.wohnort || '',
erfahrung: fd.erfahrung || '',

View file

@ -124,7 +124,8 @@ window.Page_trainingsplaene = (() => {
];
const btns = plans.map(p => `
<button class="by-tab${_activePlan === p.id ? ' active' : ''}" data-plan="${p.id}"
style="flex:1;min-width:90px;flex-direction:column;align-items:center;gap:4px">
style="flex:1;min-width:90px;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:2px;white-space:normal;text-align:center;line-height:1.2">
<span style="font-size:1.1rem">${p.label}</span>
<span style="font-size:var(--text-xs);opacity:0.8">${_esc(p.sub)}</span>
</button>`).join('');