From db386da2c0fde1a970e999e977906cf5fd9329bf Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 29 Apr 2026 06:35:42 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Welcome-Dashboard=20f=C3=BCr=20einge?= =?UTF-8?q?loggte=20User=20=E2=80=94=20Hundefoto-Hero,=20Stats-Chips,=20Fe?= =?UTF-8?q?ature-Karten=20=E2=80=94=20SW=20by-v475,=20APP=5FVER=20452?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/dogs.py | 70 ++++++- backend/static/js/api.js | 1 + backend/static/js/app.js | 2 +- backend/static/js/pages/welcome.js | 311 ++++++++++++++++++++++++++--- backend/static/sw.js | 2 +- 5 files changed, 359 insertions(+), 27 deletions(-) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 0b09ae2..a4ff043 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -8,7 +8,7 @@ from typing import Optional from database import db from auth import get_current_user from routes.push import send_push_to_user -from media_utils import safe_media_path +from media_utils import safe_media_path, preview_url_from router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -96,6 +96,74 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)): return dict(dog) +@router.get("/{dog_id}/welcome-dashboard") +async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): + """Liefert kompakte Dashboard-Daten für die Welcome-Ansicht eines Hundes.""" + import random as _random + with db() as conn: + # Besitz prüfen + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + # Zufälliges Foto aus den letzten 100 Tagebuchbildern + photos = conn.execute( + """SELECT dm.url FROM diary_media dm + JOIN diary d ON d.id = dm.diary_id + WHERE d.dog_id=? AND dm.media_type='image' + ORDER BY d.datum DESC LIMIT 100""", + (dog_id,) + ).fetchall() + random_photo = None + if photos: + chosen_url = _random.choice(photos)["url"] + random_photo = { + "url": chosen_url, + "preview_url": preview_url_from(chosen_url), + } + + # Neuester Tagebucheintrag + last_diary_row = conn.execute( + "SELECT titel, datum FROM diary WHERE dog_id=? ORDER BY datum DESC LIMIT 1", + (dog_id,) + ).fetchone() + last_diary = dict(last_diary_row) if last_diary_row else None + + # Nächster Termin (kein Gewicht) + next_appt_row = conn.execute( + """SELECT bezeichnung, naechstes, typ FROM health + WHERE dog_id=? AND naechstes IS NOT NULL AND naechstes >= date('now') + AND typ != 'gewicht' + ORDER BY naechstes ASC LIMIT 1""", + (dog_id,) + ).fetchone() + next_appointment = dict(next_appt_row) if next_appt_row else None + + # Letztes Gewicht + last_weight_row = conn.execute( + """SELECT wert, einheit, datum FROM health + WHERE dog_id=? AND typ='gewicht' + ORDER BY datum DESC LIMIT 1""", + (dog_id,) + ).fetchone() + last_weight = dict(last_weight_row) if last_weight_row else None + + # Anzahl Tagebucheinträge + diary_count = conn.execute( + "SELECT COUNT(*) AS n FROM diary WHERE dog_id=?", (dog_id,) + ).fetchone()["n"] + + return { + "random_photo": random_photo, + "last_diary": last_diary, + "next_appointment": next_appointment, + "last_weight": last_weight, + "diary_count": diary_count, + } + + @router.get("/{dog_id}") async def get_dog(dog_id: int, user=Depends(get_current_user)): with db() as conn: diff --git a/backend/static/js/api.js b/backend/static/js/api.js index acc1e60..96a3f46 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -108,6 +108,7 @@ const API = (() => { }, deletePhoto(id) { return del(`/dogs/${id}/photo`); }, getSkills(id) { return get(`/dogs/${id}/skills`); }, + welcomeDashboard(dogId) { return get(`/dogs/${dogId}/welcome-dashboard`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7a1d0d4..bc72295 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '451'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '452'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index aadd918..76ab1cb 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -255,24 +255,140 @@ window.Page_welcome = (() => { } // ---------------------------------------------------------- - // EINGELOGGTE ANSICHT — kompakter Überblick + // HILFSFUNKTIONEN (eingeloggte Ansicht) + // ---------------------------------------------------------- + function _relDate(dateStr) { + if (!dateStr) return null; + const diff = Math.floor((Date.now() - new Date(dateStr)) / 86400000); + if (diff === 0) return 'Heute'; + if (diff === 1) return 'Gestern'; + if (diff > 1 && diff < 14) return `vor ${diff} Tagen`; + if (diff === -1) return 'Morgen'; + if (diff < 0 && diff > -14) return `in ${-diff} Tagen`; + return dateStr; + } + + function _greeting() { + const h = new Date().getHours(); + if (h >= 5 && h < 11) return 'Guten Morgen'; + if (h >= 11 && h < 18) return 'Moin'; + if (h >= 18 && h < 22) return 'Guten Abend'; + return 'Hey'; + } + + function _appointmentIcon(typ) { + if (!typ) return 'calendar-check'; + const t = typ.toLowerCase(); + if (t === 'impfung') return 'syringe'; + if (t === 'tierarzt') return 'stethoscope'; + if (t === 'medikament') return 'pill'; + return 'calendar-check'; + } + + // Die 4 Feature-Karten für die eingeloggte Ansicht + const FEATURE_CARDS = [ + { icon: 'book-open', label: 'Tagebuch', desc: 'Fotos, Notizen, Erinnerungen', page: 'diary' }, + { icon: 'first-aid', label: 'Gesundheit', desc: 'Impfungen, Gewicht, Termine', page: 'health' }, + { icon: 'map-trifold', label: 'Karte', desc: 'Spots & Alerts in deiner Nähe', page: 'map' }, + { icon: 'target', label: 'Training', desc: 'Übungen mit KI-Trainer', page: 'uebungen' }, + ]; + const FEATURE_CARD_PAGES = new Set(FEATURE_CARDS.map(f => f.page)); + + function _heroHTML(dog, dashData) { + const photoUrl = dashData?.random_photo?.url || null; + const avatarUrl = dog?.foto_url || null; + const dogName = dog ? UI.escape(dog.name) : 'Dein Hund'; + const dogRasse = dog?.rasse ? UI.escape(dog.rasse) : ''; + const greeting = _greeting(); + + if (photoUrl) { + return ` +
+
+ ${avatarUrl ? `${dogName}` : ''} +
+ ${UI.escape(greeting)} +

${dogName}

+ ${dogRasse ? `

${dogRasse}

` : ''} +
+
`; + } + + // Fallback: Gradient-Hero + return ` +
+ Ban Yaro +
+ ${UI.escape(greeting)} +

${dogName}

+ ${dogRasse ? `

${dogRasse}

` : ''} +
+
`; + } + + function _chipsHTML(dashData) { + const diaryDate = dashData?.last_diary?.datum; + const diaryText = diaryDate ? (_relDate(diaryDate) || diaryDate) : '—'; + + const appt = dashData?.next_appointment; + const apptLabel = appt ? UI.escape(appt.bezeichnung) : '—'; + const apptDate = appt ? (_relDate(appt.naechstes) || appt.naechstes || '—') : '—'; + const apptIcon = _appointmentIcon(appt?.typ); + + const weight = dashData?.last_weight; + const weightVal = weight ? `${weight.wert} ${weight.einheit}` : '—'; + + return ` +
+
+ + Tagebuch + ${diaryText} +
+
+ + Nächster Termin + ${appt ? apptLabel + ' · ' + apptDate : '—'} +
+
+ + Gewicht + ${weightVal} +
+
`; + } + + // ---------------------------------------------------------- + // EINGELOGGTE ANSICHT — visueller Überblick // ---------------------------------------------------------- function _renderLoggedIn(isInstalled) { + const dog = _appState?.activeDog || null; + _container.innerHTML = ` -
+
-
- Ban Yaro -

Ban Yaro

-

- Schön, dass du wieder da bist${_appState?.user?.name ? ', ' + UI.escape(_appState.user.name) + '' : ''}! -

-
+ ${_heroHTML(dog, null)} -
-

Was Ban Yaro kann

+ ${_chipsHTML(null)} + +
+ +
+ ${FEATURE_CARDS.map(f => ` + + `).join('')} +
+ +

Mehr entdecken

- ${FEATURES.map(f => ` + ${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
`; + + // Asynchrones Dashboard laden + if (dog?.id) { + API.dogs.welcomeDashboard(dog.id).then(dash => { + _updateHeroFromDash(dash, dog); + _updateChipsFromDash(dash); + }).catch(() => { /* Skeleton bleibt sichtbar */ }); + } + } + + function _updateHeroFromDash(dash, dog) { + const heroBox = _container.querySelector('#wc-hero-box'); + if (!heroBox) return; + const photoUrl = dash?.random_photo?.url; + const avatarUrl = dog?.foto_url; + const dogName = dog ? UI.escape(dog.name) : 'Dein Hund'; + const dogRasse = dog?.rasse ? UI.escape(dog.rasse) : ''; + const greeting = _greeting(); + + if (photoUrl) { + heroBox.className = 'wc-hero wc-hero--photo'; + heroBox.style.backgroundImage = `url('${UI.escape(photoUrl)}')`; + heroBox.innerHTML = ` +
+ ${avatarUrl ? `${dogName}` : ''} +
+ ${UI.escape(greeting)} +

${dogName}

+ ${dogRasse ? `

${dogRasse}

` : ''} +
`; + } + } + + function _updateChipsFromDash(dash) { + const chipsRow = _container.querySelector('#wc-chips-row'); + if (!chipsRow) return; + chipsRow.outerHTML = _chipsHTML(dash); + // re-bind data-nav auf den neuen Chips + _container.querySelectorAll('#wc-chips-row [data-nav]').forEach(el => { + el.addEventListener('click', () => App.navigate(el.dataset.nav)); + }); } // ---------------------------------------------------------- @@ -494,25 +651,131 @@ window.Page_welcome = (() => { } .wc-install-link .ph-icon { width: 12px; height: 12px; } - /* ── Logged-in Hero (kompakt) ──────────────────────────── */ + /* ── Logged-in Hero ─────────────────────────────────────── */ .wc-hero { - background: linear-gradient(160deg, var(--c-primary-subtle) 0%, var(--c-bg) 70%); - text-align: center; - padding: var(--space-8) var(--space-4) var(--space-6); + position: relative; + min-height: 220px; + display: flex; align-items: flex-end; justify-content: center; + padding: var(--space-6) var(--space-4) var(--space-5); + overflow: hidden; border-bottom: 1px solid var(--c-border-light); + text-align: center; } - .wc-hero-icon { - width: 96px; height: 96px; border-radius: 22px; - box-shadow: 0 8px 28px rgba(0,0,0,0.15); - display: block; margin: 0 auto var(--space-4); + /* Gradient fallback */ + .wc-hero--gradient { + background: linear-gradient(160deg, var(--c-primary) 0%, color-mix(in srgb, var(--c-primary) 60%, var(--c-bg)) 100%); + flex-direction: column; align-items: center; justify-content: center; + } + /* Photo variant */ + .wc-hero--photo { + background-size: cover; background-position: center; + min-height: 260px; + flex-direction: column; align-items: center; justify-content: flex-end; + } + .wc-hero-overlay { + position: absolute; inset: 0; + background: linear-gradient(to bottom, rgba(0,0,0,0.08) 0%, rgba(0,0,0,0.55) 100%); + pointer-events: none; + } + .wc-hero-avatar { + position: absolute; top: var(--space-4); right: var(--space-4); + width: 52px; height: 52px; border-radius: 50%; + border: 2.5px solid rgba(255,255,255,0.7); + object-fit: cover; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 2; + } + .wc-hero-center { + position: relative; z-index: 2; + display: flex; flex-direction: column; align-items: center; + gap: 2px; + } + .wc-hero-greeting { + font-size: var(--text-sm); font-weight: var(--weight-semibold); + color: rgba(255,255,255,0.82); letter-spacing: 0.04em; + text-transform: uppercase; } .wc-hero-title { font-size: var(--text-2xl); font-weight: var(--weight-bold); - color: var(--c-text); margin: 0 0 var(--space-2); + color: #fff; margin: 0; + text-shadow: 0 2px 8px rgba(0,0,0,0.25); } - .wc-hero-sub { - font-size: var(--text-base); color: var(--c-text-secondary); - margin: 0 auto; line-height: 1.6; max-width: 420px; + .wc-hero-rasse { + font-size: var(--text-sm); color: rgba(255,255,255,0.72); + margin: 0; line-height: 1.4; + } + .wc-hero-icon { + width: 72px; height: 72px; border-radius: 18px; + box-shadow: 0 6px 20px rgba(0,0,0,0.2); + display: block; margin: 0 auto var(--space-3); + position: relative; z-index: 2; + } + + /* ── Stats-Chips ─────────────────────────────────────────── */ + .wc-chips { + display: flex; gap: var(--space-3); + padding: var(--space-3) var(--space-4); + overflow-x: auto; scrollbar-width: none; + background: var(--c-surface); + border-bottom: 1px solid var(--c-border-light); + -webkit-overflow-scrolling: touch; + } + .wc-chips::-webkit-scrollbar { display: none; } + .wc-chip { + display: flex; flex-direction: column; gap: 2px; + flex-shrink: 0; + background: var(--c-bg); border: 1px solid var(--c-border-light); + border-radius: var(--radius-lg); + padding: var(--space-3) var(--space-4); + cursor: pointer; min-width: 130px; + transition: background 0.15s; + } + .wc-chip:hover { background: var(--c-surface-2, var(--c-surface)); } + .wc-chip:active { transform: scale(0.97); } + .wc-chip-icon { width: 18px; height: 18px; color: var(--c-primary); margin-bottom: 2px; } + .wc-chip-label { + font-size: var(--text-xs); color: var(--c-text-muted); + font-weight: var(--weight-semibold); text-transform: uppercase; + letter-spacing: 0.05em; white-space: nowrap; + } + .wc-chip-val { + font-size: var(--text-sm); color: var(--c-text); + font-weight: var(--weight-semibold); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; max-width: 180px; + } + .wc-chip-val--empty { color: var(--c-text-muted); font-weight: normal; } + + /* ── Feature-Karten (2×2) ────────────────────────────────── */ + .wc-li-body { padding: var(--space-5) var(--space-4); } + .wc-feature-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); + margin-bottom: var(--space-2); + } + .wc-fcard { + display: flex; flex-direction: column; align-items: flex-start; + gap: var(--space-2); padding: var(--space-4); + background: var(--c-surface); border: 1px solid var(--c-border-light); + border-radius: var(--radius-lg); cursor: pointer; text-align: left; + transition: background 0.15s, transform 0.1s; + } + .wc-fcard:hover { background: var(--c-surface-2, var(--c-surface)); } + .wc-fcard:active { transform: scale(0.97); } + .wc-fcard-icon { + width: 44px; height: 44px; border-radius: var(--radius-md); + background: var(--c-primary-subtle); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + .wc-fcard-icon .ph-icon { width: 22px; height: 22px; color: var(--c-primary); } + .wc-fcard-title { + font-size: var(--text-sm); font-weight: var(--weight-bold); + color: var(--c-text); line-height: 1.2; + } + .wc-fcard-desc { + font-size: var(--text-xs); color: var(--c-text-secondary); + line-height: 1.4; } /* ── Shared ────────────────────────────────────────────── */ diff --git a/backend/static/sw.js b/backend/static/sw.js index a405935..14bc531 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v474'; +const CACHE_VERSION = 'by-v475'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten