Sprint 15: Zeitzone-Fix, Gewichts-Sync, Öffnungszeiten, KI-Bericht, POI-Moderation — SW by-v432, APP_VER 411

- client_time: Browser-Lokalzeit bei allen Creates mitschicken (Tagebuch, Notizen,
  Forum, Verlorener Hund, Routen) — kein UTC-Versatz mehr bei Einträgen
- Gewicht-Sync: health typ=gewicht schreibt dogs.gewicht_kg, einmalige Migration
- Praxen: opening_hours + lat/lon/osm_id in tieraerzte-Tabelle, OSM-Nearby-Lookup,
  Öffnungszeiten in Karte und Detailansicht
- KI-Gesundheitsbericht: alle 2 Wochen automatisch, ki_health_reports-Tabelle,
  Frontend-Banner mit Archiv (letzten 5 Berichte)
- POI-Korrekturen: User schlägt Öffnungszeiten-Änderung vor, Moderatoren-Tab
  genehmigt/lehnt ab, user_edited-Flag schützt vor Overpass-Überschreibung
- timeutils.py: safe_client_time() zentral für alle Routen
This commit is contained in:
rene 2026-04-26 15:38:50 +02:00
parent 679dbdd862
commit 06bd8525ed
21 changed files with 724 additions and 75 deletions

View file

@ -1732,6 +1732,7 @@ window.Page_diary = (() => {
gps_lat: _locLat,
gps_lon: _locLon,
location_name: _locName,
client_time: API.clientNow(),
};
async function _uploadNewFiles(entryId) {
@ -2012,7 +2013,7 @@ window.Page_diary = (() => {
const text = textarea.value.trim();
UI.setLoading(saveBtn, true);
try {
const payload = { text, parent_label: parentLabel, location_name: locationName };
const payload = { text, parent_label: parentLabel, location_name: locationName, client_time: API.clientNow() };
if (existingNoteId) {
await API.notes.update(existingNoteId, payload);
} else {

View file

@ -531,7 +531,7 @@ window.Page_forum = (() => {
if (!text) { UI.toast.warning('Bitte Text eingeben.'); return; }
await UI.asyncButton(btn, async () => {
const post = await API.forum.addPost(thread.id, { text });
const post = await API.forum.addPost(thread.id, { text, client_time: API.clientNow() });
// Foto hochladen falls vorhanden
const files = Array.from(document.getElementById('forum-reply-file')?.files || []);
@ -900,12 +900,13 @@ window.Page_forum = (() => {
const loc = _picker ? _picker.getValue() : { lat: null, lon: null, name: null };
const created = await API.forum.create({
kategorie: fd.kategorie,
titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(),
thread_lat: loc.lat ?? null,
thread_lon: loc.lon ?? null,
thread_ort: loc.name ?? null,
kategorie: fd.kategorie,
titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(),
thread_lat: loc.lat ?? null,
thread_lon: loc.lon ?? null,
thread_ort: loc.name ?? null,
client_time: API.clientNow(),
});
// Fotos hochladen

View file

@ -152,6 +152,7 @@ window.Page_health = (() => {
</button>
</div>
${transponderHtml}
<div id="health-ki-berichte"></div>
<div id="health-reminders"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
@ -166,6 +167,7 @@ window.Page_health = (() => {
await _loadAll();
_renderErinnerungen();
_renderTab();
_loadKiBerichte(dog.id);
}
// ----------------------------------------------------------
@ -1009,7 +1011,8 @@ window.Page_health = (() => {
if (praxis) {
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const tel = praxis.telefon ? ` · <a href="tel:${_esc(praxis.telefon)}">${_esc(praxis.telefon)}</a>` : '';
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
const oh = praxis.opening_hours ? `<br><small style="color:var(--c-text-secondary)">🕐 ${_esc(_fmtOeffnungszeiten(praxis.opening_hours))}</small>` : '';
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}${oh}`]);
}
} else if (e.tierarzt_name) {
rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
@ -1561,6 +1564,11 @@ window.Page_health = (() => {
<div class="health-card-meta">
${[p.strasse, [p.plz, p.ort].filter(Boolean).join(' ')].filter(Boolean).map(_esc).join(', ')}
</div>` : ''}
${p.opening_hours ? `
<div class="health-card-meta" style="margin-top:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="font-size:0.9em"><use href="/icons/phosphor.svg#clock"></use></svg>
${_esc(_fmtOeffnungszeiten(p.opening_hours))}
</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
${p.telefon ? `
<a href="tel:${_esc(p.telefon)}" class="btn btn-secondary btn-sm"
@ -1642,10 +1650,24 @@ window.Page_health = (() => {
<input class="form-control" type="email" name="email"
value="${_esc(praxis?.email || '')}" placeholder="praxis@beispiel.de">
</div>
<div class="form-group">
<label class="form-label">
Öffnungszeiten
<button type="button" id="praxis-osm-lookup" class="btn btn-secondary btn-sm"
style="margin-left:var(--space-2);font-size:var(--text-xs)">
📍 Aus Karte laden
</button>
</label>
<input class="form-control" type="text" name="opening_hours"
id="praxis-opening-hours"
value="${_esc(praxis?.opening_hours || '')}"
placeholder="z.B. MoFr 08:0018:00 · Sa 09:0013:00">
<div id="praxis-osm-results" style="display:none;margin-top:var(--space-2)"></div>
</div>
<div class="form-group">
<label class="form-label">Notizen</label>
<textarea class="form-control" name="notizen" rows="2"
placeholder="Öffnungszeiten, Besonderheiten…">${_esc(praxis?.notizen || '')}</textarea>
placeholder="Besonderheiten, interne Hinweise…">${_esc(praxis?.notizen || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
@ -1670,6 +1692,68 @@ window.Page_health = (() => {
document.getElementById('praxis-cancel')?.addEventListener('click', UI.modal.close);
// OSM-Lookup: Tierarztpraxen in der Nähe suchen und Öffnungszeiten übernehmen
document.getElementById('praxis-osm-lookup')?.addEventListener('click', async btn => {
const lookupBtn = document.getElementById('praxis-osm-lookup');
const resultsEl = document.getElementById('praxis-osm-results');
lookupBtn.disabled = true;
lookupBtn.textContent = 'Suche…';
try {
const pos = await API.getLocation();
const hits = await API.tieraerzte.osmNearby(pos.lat, pos.lon);
if (!hits.length) {
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">Keine Praxen in der Nähe im OSM-Cache gefunden.</p>';
} else {
resultsEl.style.display = 'block';
resultsEl.innerHTML = hits.map(h => `
<div class="health-card" style="margin-bottom:var(--space-2)">
<div style="cursor:pointer;flex:1"
data-osm-id="${_esc(h.osm_id)}"
data-name="${_esc(h.name)}"
data-oh="${_esc(h.opening_hours || '')}"
data-phone="${_esc(h.phone || '')}"
data-action="pick-osm">
<div style="font-weight:600">${_esc(h.name)}</div>
${h.opening_hours_fmt ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(h.opening_hours_fmt)}</div>` : '<div style="font-size:var(--text-sm);color:var(--c-text-muted)">Öffnungszeiten unbekannt</div>'}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${h.distanz_km} km entfernt</div>
</div>
<button class="btn btn-secondary btn-sm" style="flex-shrink:0;align-self:flex-start"
data-action="korrigieren"
data-osm-id="${_esc(h.osm_id)}"
data-poi-name="${_esc(h.name)}"
data-current-oh="${_esc(h.opening_hours || '')}">
</button>
</div>
`).join('');
resultsEl.querySelectorAll('[data-action="pick-osm"]').forEach(el => {
el.addEventListener('click', () => {
const nameInput = document.querySelector('[name="name"]');
const ohInput = document.getElementById('praxis-opening-hours');
const telInput = document.querySelector('[name="telefon"]');
if (nameInput && !nameInput.value) nameInput.value = el.dataset.name;
if (ohInput) ohInput.value = el.dataset.oh;
if (telInput && !telInput.value) telInput.value = el.dataset.phone;
resultsEl.style.display = 'none';
UI.toast.success('Daten übernommen.');
});
});
resultsEl.querySelectorAll('[data-action="korrigieren"]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_showPoiKorrekturModal(btn.dataset.osmId, btn.dataset.poiName, btn.dataset.currentOh);
});
});
}
} catch (err) {
UI.toast.warning('Standort nicht verfügbar oder kein OSM-Cache in der Nähe.');
} finally {
lookupBtn.disabled = false;
lookupBtn.textContent = '📍 Aus Karte laden';
}
});
document.getElementById('praxis-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="praxis-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
@ -1686,6 +1770,7 @@ window.Page_health = (() => {
email: fd.email || null,
notizen: fd.notizen || null,
ist_notfallpraxis: 'ist_notfallpraxis' in fd,
opening_hours: fd.opening_hours || null,
};
if (!payload.name) { UI.toast.warning('Bitte einen Namen eingeben.'); return; }
@ -1850,6 +1935,59 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte)
// ----------------------------------------------------------
async function _loadKiBerichte(dogId) {
const el = _container.querySelector('#health-ki-berichte');
if (!el) return;
try {
const berichte = await API.health.kiBerichte(dogId);
if (!berichte || berichte.length === 0) return;
const neuester = berichte[0];
const datum = neuester.erstellt_at
? new Date(neuester.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
const preview = neuester.bericht.length > 180
? _esc(neuester.bericht.slice(0, 180)) + '&hellip;'
: _esc(neuester.bericht);
el.innerHTML = `
<div class="health-ki-bericht-banner" style="
background:var(--c-surface-2,#f7f2eb);
border:1px solid var(--c-border,#e2d9ce);
border-radius:var(--radius-md,10px);
padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-3);
cursor:pointer;
">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0"><use href="/icons/phosphor.svg#star"></use></svg>
<strong style="font-size:var(--text-sm)">KI-Gesundheitsbericht</strong>
${datum ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted);margin-left:auto">${datum}</span>` : ''}
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-muted);line-height:1.5">${preview}</div>
${berichte.length > 1 ? `<div style="font-size:var(--text-xs);color:var(--c-accent,#c4843a);margin-top:var(--space-1)">${berichte.length} Berichte gespeichert — zum Öffnen tippen</div>` : ''}
</div>`;
el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => {
const listeHtml = berichte.map((b, i) => {
const d = b.erstellt_at
? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '';
return `<div style="${i > 0 ? 'border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-3)' : ''}">
${d ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">${d}</div>` : ''}
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div>
</div>`;
}).join('');
UI.modal.open({
title: `${UI.icon('star')} KI-Gesundheitsberichte`,
body: listeHtml,
});
});
} catch (_) {
// Silently ignore — Berichte sind optional
}
}
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
@ -1886,6 +2024,56 @@ window.Page_health = (() => {
.replace(/"/g, '&quot;');
}
function _showPoiKorrekturModal(osmId, poiName, currentOh) {
UI.modal.open({
title: 'Öffnungszeiten korrigieren',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
Korrektur für <strong>${_esc(poiName)}</strong>.<br>
Dein Vorschlag wird von einem Moderator geprüft und dann für alle übernommen.
</p>
<form id="poi-korrektur-form">
<div class="form-group">
<label class="form-label">Aktuelle Angabe</label>
<input class="form-control" type="text" value="${_esc(currentOh)}" disabled
style="color:var(--c-text-muted)">
</div>
<div class="form-group">
<label class="form-label">Korrekte Öffnungszeiten *</label>
<input class="form-control" type="text" name="new_value" required
placeholder="z.B. MoFr 08:0018:00 · Sa 09:0013:00"
value="${_esc(currentOh)}">
</div>
</form>
`,
footer: `
<button type="button" class="btn btn-secondary flex-1" id="poi-kor-cancel">Abbrechen</button>
<button type="submit" form="poi-korrektur-form" class="btn btn-primary flex-1">Einreichen</button>
`,
});
document.getElementById('poi-kor-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('poi-korrektur-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="poi-korrektur-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.post(`/osm/pois/${encodeURIComponent(osmId)}/edit`, {
poi_name: poiName,
field: 'opening_hours',
new_value: fd.new_value.trim(),
});
UI.modal.close();
UI.toast.success('Danke! Dein Vorschlag wird geprüft.');
});
});
}
function _fmtOeffnungszeiten(raw) {
if (!raw) return '';
if (raw.trim().toLowerCase() === '24/7') return '24/7 geöffnet';
return raw.split(';').map(s => s.trim()).filter(Boolean).join(' · ');
}
// ----------------------------------------------------------
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------

View file

@ -629,6 +629,7 @@ window.Page_lost = (() => {
lat : parseFloat(fd.lat),
lon : parseFloat(fd.lon),
dog_id : fd.dog_id ? parseInt(fd.dog_id) : null,
client_time : API.clientNow(),
};
const created = await API.lost.report(payload);

View file

@ -14,6 +14,7 @@ window.Page_moderation = (() => {
{ id: 'fotos', label: 'Fotos', icon: 'image' },
{ id: 'user', label: 'User', icon: 'users' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
{ id: 'poi-edits', label: 'POI-Edits', icon: 'clock' },
];
// ------------------------------------------------------------------
@ -77,6 +78,7 @@ window.Page_moderation = (() => {
case 'fotos': await _renderFotos(el); break;
case 'user': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
case 'poi-edits': await _renderPoiEdits(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -106,6 +108,10 @@ window.Page_moderation = (() => {
'Züchter ausstehend',
s.pending_zuchter,
s.pending_zuchter > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
${_statCard('clock',
'POI-Korrekturen',
s.pending_poi_edits ?? 0,
(s.pending_poi_edits ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)')}
</div>
<div class="card" style="padding:var(--space-4);margin-top:var(--space-4)">
@ -456,6 +462,76 @@ window.Page_moderation = (() => {
`;
}
// ------------------------------------------------------------------
// TAB: POI-KORREKTUREN
// ------------------------------------------------------------------
async function _renderPoiEdits(el) {
const edits = await API.get('/moderation/poi-edits');
if (!edits.length) {
el.innerHTML = _emptyState('check-circle', 'Alles erledigt', 'Keine ausstehenden POI-Korrekturen.');
return;
}
const STATUS_LABEL = { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' };
const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' };
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${edits.map(e => `
<div class="card" style="padding:var(--space-4)" data-edit-id="${e.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-2);flex-wrap:wrap">
<div>
<div style="font-weight:600">${_esc(e.poi_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)}
· ${new Date(e.created_at).toLocaleDateString('de-DE')}
</div>
</div>
<span style="font-size:var(--text-xs);font-weight:600;color:${STATUS_COLOR[e.status] || 'inherit'}">
${STATUS_LABEL[e.status] || e.status}
</span>
</div>
<div style="margin-top:var(--space-3);display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Aktuell</div>
<div style="font-size:var(--text-sm)">${_esc(e.old_value) || '<em style="color:var(--c-text-muted)">leer</em>'}</div>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${_esc(e.new_value)}</div>
</div>
</div>
${e.status === 'pending' ? `
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button class="btn btn-primary btn-sm flex-1" data-action="approve" data-id="${e.id}">
Übernehmen
</button>
<button class="btn btn-secondary btn-sm flex-1" data-action="reject" data-id="${e.id}">
Ablehnen
</button>
</div>` : ''}
</div>
`).join('')}
</div>
`;
el.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const action = btn.dataset.action;
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${id}`, { action });
UI.toast.success(action === 'approve' ? 'Übernommen!' : 'Abgelehnt.');
await _renderPoiEdits(el);
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
btn.disabled = false;
}
});
});
}
function _esc(s) {
if (!s) return '';
return String(s)

View file

@ -630,6 +630,7 @@ window.Page_routes = (() => {
leine_empfohlen: 'leine_empfohlen' in fd,
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
client_time: API.clientNow(),
});
UI.modal.close();
UI.toast.success(`Route „${saved.name}" gespeichert!`);
@ -2428,6 +2429,7 @@ window.Page_routes = (() => {
leine_empfohlen: document.getElementById('ri-leine')?.checked,
is_public: document.getElementById('ri-public')?.checked,
hunde_tauglichkeit: _selPaw,
client_time: API.clientNow(),
});
UI.modal.close();
UI.toast.success('Route importiert! 🥾');
@ -2551,7 +2553,7 @@ window.Page_routes = (() => {
ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#rk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);