Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)

- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
This commit is contained in:
rene 2026-05-02 09:29:48 +02:00
parent 031c6028ac
commit 742ad189e8
26 changed files with 5734 additions and 27 deletions

View file

@ -463,6 +463,8 @@ window.Page_welcome = (() => {
`).join('')}
</div>
${dog?.id ? `<div id="wc-streak-widget"></div>` : ''}
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
<div class="wc-grid">
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
@ -497,9 +499,85 @@ window.Page_welcome = (() => {
_updateChipsFromDash(dash);
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
}).catch(() => { /* Skeleton bleibt sichtbar */ });
// Streak-Widget asynchron laden
_loadStreakWidget(dog.id);
}
}
// ----------------------------------------------------------
// STREAK-WIDGET
// ----------------------------------------------------------
async function _loadStreakWidget(dogId) {
const slot = _container.querySelector('#wc-streak-widget');
if (!slot) return;
let streak;
try {
streak = await API.get(`/streak/${dogId}`);
} catch { return; }
if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return;
slot.innerHTML = _streakWidgetHTML(streak);
slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => {
const modalEl = UI.modal.open({
title: '🔥 Trainings-Bestenliste',
body: '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Wird geladen…</p>',
});
let board;
try { board = await API.get('/streak/leaderboard'); } catch { board = []; }
const bodyEl = modalEl?.querySelector('.modal-body');
if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board);
});
}
function _streakWidgetHTML(s) {
const cur = s.current_streak || 0;
const best = s.longest_streak || 0;
return `
<div class="wc-streak-card">
<div class="wc-streak-flame-wrap">
<span class="wc-streak-flame">🔥</span>
<span class="wc-streak-number">${cur}</span>
</div>
<div class="wc-streak-info">
<div class="wc-streak-label">Tage in Folge trainiert</div>
<div class="wc-streak-best">Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}</div>
</div>
<button class="wc-streak-lb-btn" id="wc-streak-leaderboard-btn" title="Bestenliste">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
</button>
</div>`;
}
function _leaderboardHTML(rows) {
if (!rows || !rows.length) {
return '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Einträge.</p>';
}
const medals = ['🥇', '🥈', '🥉'];
return `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${rows.map((r, i) => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-subtle)">
<span style="font-size:1.4rem;width:28px;text-align:center;flex-shrink:0">${medals[i] || (i + 1) + '.'}</span>
${r.foto_url
? `<img src="${UI.escape(r.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0" alt="">`
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary-subtle);flex-shrink:0"></div>`}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${UI.escape(r.dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<span style="font-size:1.1rem">🔥</span>
<span style="font-weight:var(--weight-bold);font-size:var(--text-base);color:var(--c-primary)">${r.current_streak}</span>
</div>
</div>
`).join('')}
</div>`;
}
function _updateHeroFromDash(dash, dog) {
const heroBox = _container.querySelector('#wc-hero-box');
if (!heroBox) return;