Feature: Generische Seiten-Hilfe (UI.pageInfo), POI Multi-Select, Tagessprüche-DB (SW by-v654)

- UI.pageInfo(): generische Hilfe-Funktion — erstes Öffnen zeigt Info-Banner, danach ? Button oben rechts; CSS-Klassen pinfo-*
- Übungen-Seite nutzt UI.pageInfo() als erstes Beispiel
- Karte POI: Mehrfachauswahl (außer Giftköder), Kombi-Typen entfernt, type als comma-separated im Backend
- daily_quotes Tabelle in DB (346 Einträge via import_quotes.py importiert)
- GET /widget/quote — deterministischer Tagesspruch (wechselt täglich)
This commit is contained in:
rene 2026-05-03 20:10:01 +02:00
parent 1fdba57365
commit 9103c7950f
12 changed files with 623 additions and 38 deletions

View file

@ -1928,6 +1928,18 @@ def _migrate(conn_factory):
if 'world_config' not in existing_u:
conn.execute("ALTER TABLE users ADD COLUMN world_config TEXT")
# Tagessprüche-Pool
conn.executescript("""
CREATE TABLE IF NOT EXISTS daily_quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
autor TEXT,
kategorie TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dq_kategorie ON daily_quotes(kategorie);
""")
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (

View file

@ -279,21 +279,19 @@ class UserPoiIn(BaseModel):
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
'kotbeutel', # Kotbeutelspender
'kotbeutel_abfall', # Kotbeutelspender + Mülleimer Kombi
'bank', # Sitzbank
'bank_kotbeutel', # Sitzbank + Kotbeutelspender
'bank_kotbeutel_abfall', # Sitzbank + Kotbeutelspender + Mülleimer
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
'giftkoeder', # Giftköder (exklusiv, kein Kombi)
'kotbeutel', # Kotbeutelspender
'bank', # Sitzbank
'gefahr', # Allgemeine Gefahr / Hinweis
'parkplatz', # Hundefreundlicher Parkplatz
'treffpunkt', # Treffpunkt für Hundehalter
'sonstiges',
}
@router.post('/user-poi')
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
if body.type not in ALLOWED_TYPES:
types = [t.strip() for t in body.type.split(',') if t.strip()]
if not types or any(t not in ALLOWED_TYPES for t in types):
raise HTTPException(400, 'Ungültiger Typ')
with db() as conn:
row = conn.execute("""

View file

@ -1,13 +1,33 @@
"""BAN YARO — Widget-Snapshot Endpoint"""
"""BAN YARO — Widget-Snapshot + Tagesspruch Endpoints"""
import json, random
from fastapi import APIRouter, Depends
from datetime import date
from fastapi import APIRouter, Depends, Query
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
@router.get("/quote")
async def daily_quote(kategorie: Optional[str] = Query(None)):
"""Liefert einen deterministischen Tagesspruch (wechselt täglich)."""
day_num = (date.today() - date(2026, 1, 1)).days
with db() as conn:
if kategorie:
rows = conn.execute(
"SELECT id, text, autor, kategorie FROM daily_quotes WHERE kategorie=?",
(kategorie,)
).fetchall()
else:
rows = conn.execute("SELECT id, text, autor, kategorie FROM daily_quotes").fetchall()
if not rows:
return {"quote": None}
q = rows[day_num % len(rows)]
return {"quote": dict(q)}
@router.get("/snapshot")
async def widget_snapshot(user=Depends(get_current_user)):
"""Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild."""

View file

@ -6550,6 +6550,104 @@ html.modal-open {
/* ============================================================
HELP TOOLTIP
============================================================ */
/* ============================================================
PAGE INFO generische Seiten-Hilfe (UI.pageInfo)
============================================================ */
.pinfo-trigger {
position: absolute;
top: calc(env(safe-area-inset-top, 0px) + 10px);
right: var(--space-4);
width: 32px; height: 32px;
border-radius: 50%;
background: var(--c-surface-2);
border: 1px solid var(--c-border-light);
color: var(--c-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
flex-shrink: 0;
box-shadow: var(--shadow-sm);
transition: background .15s, color .15s;
}
.pinfo-trigger:hover { background: var(--c-primary-subtle, rgba(196,132,58,.1)); color: var(--c-primary); }
.pinfo-banner {
margin: var(--space-3) var(--space-4) 0;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--c-surface-2);
border-left: 3px solid var(--c-primary);
font-size: var(--text-sm);
}
.pinfo-banner-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.pinfo-banner-icon { color: var(--c-primary); flex-shrink: 0; }
.pinfo-banner-title {
flex: 1;
font-weight: var(--weight-semibold);
color: var(--c-text);
}
.pinfo-banner-close {
background: none; border: none; cursor: pointer;
color: var(--c-text-muted); padding: 2px;
}
.pinfo-banner-intro { color: var(--c-text-secondary); margin-bottom: var(--space-2); line-height: 1.5; }
.pinfo-banner-more {
background: none; border: none; cursor: pointer;
color: var(--c-primary);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
padding: 0;
display: flex;
align-items: center;
gap: 4px;
margin-top: var(--space-2);
}
/* MODAL BODY */
.pinfo-modal { display: flex; flex-direction: column; gap: var(--space-3); }
.pinfo-intro { color: var(--c-text-secondary); line-height: 1.6; margin: 0; }
.pinfo-steps { display: flex; flex-direction: column; gap: var(--space-3); }
.pinfo-steps--compact { gap: var(--space-2); margin-top: var(--space-2); }
.pinfo-step {
display: flex;
gap: var(--space-3);
align-items: flex-start;
}
.pinfo-step-icon {
width: 32px; height: 32px;
border-radius: var(--radius-md);
background: var(--c-primary-subtle, rgba(196,132,58,.12));
color: var(--c-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.pinfo-step-title { font-weight: var(--weight-semibold); color: var(--c-text); font-size: var(--text-sm); margin-bottom: 2px; }
.pinfo-step-text { color: var(--c-text-secondary); font-size: var(--text-sm); line-height: 1.5; }
.pinfo-tip {
display: flex;
gap: var(--space-2);
align-items: flex-start;
padding: var(--space-3);
background: rgba(196,132,58,.08);
border-radius: var(--radius-md);
color: var(--c-text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.pinfo-tip .ph-icon { color: var(--c-primary); flex-shrink: 0; margin-top: 1px; }
/* Container braucht position:relative für den absoluten Trigger-Button */
.page-body { position: relative; }
.by-help-btn {
display: inline-flex;
align-items: center;

View file

@ -93,9 +93,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=653">
<link rel="stylesheet" href="/css/layout.css?v=653">
<link rel="stylesheet" href="/css/components.css?v=653">
<link rel="stylesheet" href="/css/design-system.css?v=654">
<link rel="stylesheet" href="/css/layout.css?v=654">
<link rel="stylesheet" href="/css/components.css?v=654">
</head>
<body>
@ -562,7 +562,7 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=653"></script>
<script src="/js/worlds.js?v=654"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '653'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '654'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -838,19 +838,17 @@ window.Page_map = (() => {
_tempMarker = null;
}
// Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626' },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'kotbeutel_abfall', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel + Mülleimer', color: '#5a8a6a' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'bank_kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Bank + Kotbeutel', color: '#7a6030' },
{ type: 'bank_kotbeutel_abfall', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Bank + Kotbeutel + Mülleimer', color: '#4a5a2a' },
{ type: 'drinking_water', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
@ -859,18 +857,18 @@ window.Page_map = (() => {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
let _selectedType = 'giftkoeder';
let _selectedTypes = new Set(['giftkoeder']);
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
body: `
<form id="poi-form" class="flex flex-col gap-3">
<div>
<label class="form-label">Typ auswählen</label>
<label class="form-label">Typ auswählen <span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:normal">(Mehrfachauswahl möglich)</span></label>
<div class="poi-type-grid">
${PIN_TYPES.map(p => `
<button type="button" class="poi-type-btn${p.type === 'giftkoeder' ? ' selected' : ''}"
data-type="${p.type}" style="--pt-color:${p.color}">
data-type="${p.type}" data-excl="${p.exclusive ? '1' : ''}" style="--pt-color:${p.color}">
<span class="poi-type-icon">${p.icon}</span>
<span class="poi-type-label">${p.label}</span>
</button>
@ -896,9 +894,21 @@ window.Page_map = (() => {
document.querySelector('.poi-type-grid')?.addEventListener('click', e => {
const btn = e.target.closest('.poi-type-btn');
if (!btn) return;
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
_selectedType = btn.dataset.type;
const t = btn.dataset.type;
if (btn.dataset.excl) {
_selectedTypes = new Set([t]);
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.toggle('selected', b.dataset.type === t));
} else {
if (_selectedTypes.has('giftkoeder')) {
_selectedTypes.delete('giftkoeder');
document.querySelector('[data-excl="1"]')?.classList.remove('selected');
}
if (_selectedTypes.has(t)) {
if (_selectedTypes.size > 1) { _selectedTypes.delete(t); btn.classList.remove('selected'); }
} else {
_selectedTypes.add(t); btn.classList.add('selected');
}
}
});
document.getElementById('poi-cancel')?.addEventListener('click', () => {
@ -909,8 +919,9 @@ window.Page_map = (() => {
document.getElementById('poi-save')?.addEventListener('click', async () => {
const name = document.getElementById('poi-name').value.trim() || null;
const notiz = document.getElementById('poi-notiz').value.trim() || null;
const type = [..._selectedTypes].join(',');
UI.modal.close();
await _saveUserPoi({ type: _selectedType, lat: latlng.lat, lon: latlng.lng, name, notiz });
await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz });
_exitPlacementMode();
});
}

View file

@ -476,6 +476,18 @@ window.Page_uebungen = (() => {
if (_VALID_TABS.has(mapped)) _activeTab = mapped;
}
_render();
UI.pageInfo(_container, {
pageId: 'uebungen',
title: 'Übungsbibliothek',
icon: 'graduation-cap',
intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.',
steps: [
{ icon: 'list-checks', title: 'Stand erfassen', text: 'Klicke auf "Stand erfassen" um schnell für alle Übungen einzutragen, was euer aktueller Stand ist.' },
{ icon: 'flag', title: 'Übung üben', text: 'Tippe auf eine Übung, um die Anleitung zu lesen. Mit den Fortschritts-Icons (Flagge → Trophäe) trackst du, wie weit ihr seid.' },
{ icon: 'star', title: 'KI-Trainer', text: 'Im Tab "KI-Trainer" analysiert unsere KI deinen Trainingsstand und gibt personalisierte Empfehlungen.' },
],
tip: 'Regelmäßiges Training stärkt die Bindung — auch 5 Minuten täglich machen einen großen Unterschied!',
});
// Übungen aus DB laden (parallel mit Progress)
if (!_exercisesLoaded) {

View file

@ -280,6 +280,68 @@ const UI = (() => {
// Alias für ältere Aufrufe
const escHtml = escape;
// ----------------------------------------------------------
// PAGE INFO — generische Seiten-Hilfe
// config: { pageId, title, icon?, intro, steps?: [{icon,title,text}], tip? }
// Erstes Öffnen: expandierter Banner. Danach: kleines ? im Header.
// ----------------------------------------------------------
function pageInfo(container, config) {
const seenKey = 'help_seen_' + config.pageId;
const seen = !!localStorage.getItem(seenKey);
function _buildSteps() {
if (!config.steps?.length) return '';
return config.steps.map(s => `
<div class="pinfo-step">
${s.icon ? `<span class="pinfo-step-icon">${_svgIcon(s.icon)}</span>` : ''}
<div>
${s.title ? `<div class="pinfo-step-title">${s.title}</div>` : ''}
<div class="pinfo-step-text">${s.text}</div>
</div>
</div>`).join('');
}
function _openModal() {
modal.open({
title: `${_svgIcon(config.icon || 'question')} ${config.title}`,
body: `
<div class="pinfo-modal">
<p class="pinfo-intro">${config.intro}</p>
${config.steps?.length ? `<div class="pinfo-steps">${_buildSteps()}</div>` : ''}
${config.tip ? `<div class="pinfo-tip">${_svgIcon('lightbulb')} ${config.tip}</div>` : ''}
</div>`,
});
}
// Kleiner ? Button oben rechts — immer sichtbar
const headerBtn = document.createElement('button');
headerBtn.className = 'pinfo-trigger';
headerBtn.setAttribute('aria-label', 'Hilfe');
headerBtn.innerHTML = _svgIcon('question');
headerBtn.addEventListener('click', _openModal);
container.appendChild(headerBtn);
// Banner beim ersten Besuch
if (!seen) {
localStorage.setItem(seenKey, '1');
const banner = document.createElement('div');
banner.className = 'pinfo-banner';
banner.innerHTML = `
<div class="pinfo-banner-head">
<span class="pinfo-banner-icon">${_svgIcon(config.icon || 'info')}</span>
<span class="pinfo-banner-title">${config.title}</span>
<button class="pinfo-banner-close" aria-label="Schließen">${_svgIcon('x')}</button>
</div>
<div class="pinfo-banner-intro">${config.intro}</div>
${config.steps?.length ? `<div class="pinfo-steps pinfo-steps--compact">${_buildSteps()}</div>` : ''}
<button class="pinfo-banner-more">Mehr erfahren ${_svgIcon('arrow-right')}</button>
`;
banner.querySelector('.pinfo-banner-close').addEventListener('click', () => banner.remove());
banner.querySelector('.pinfo-banner-more').addEventListener('click', () => { banner.remove(); _openModal(); });
container.insertAdjacentElement('afterbegin', banner);
}
}
// ----------------------------------------------------------
// HELP TOOLTIP — inline ? Badge mit Klick-Tooltip
// ----------------------------------------------------------
@ -915,7 +977,7 @@ const UI = (() => {
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
escape, escHtml, help,
escape, escHtml, help, pageInfo,
saveToAlbum,
loadLeaflet,
leafletMarker,

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v653';
const CACHE_VERSION = 'by-v654';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache