Session 2026-04-21: SEO, Wiki-Anreicherung, Training, Lober
SEO & Crawler:
- robots.txt, llms.txt, sitemap.xml (508 Seiten bei Google)
- SSR-Seiten: /info, /wiki/rassen, /wiki/rasse/{slug}, /knigge
- Open Graph, JSON-LD, Breadcrumbs in index.html
Navigation:
- Training unter "Mein Hund", Wissen collapsible
- Welcome-Seite und Landing-Page auf 5-Gruppen-Struktur
Wiki:
- KI-Anreicherung (Claude API): beschreibung, vorkommen_de, Steckbrief
- "So einen hab ich" / Züchter-Verzeichnis
- Scheduler: 50 Rassen beim Start, 20/Nacht
Training:
- Session-Logging (Erfolgsquote, Stimmung, Zufriedenheit)
- Virtueller KI-Trainer (6h-Cache)
- Trainingskalender (Habit-Tracker)
- Top-Training → automatischer Tagebucheintrag
- Gamification ohne Druck: Badges, Streak, Stats
Fortschritts-Lober:
- Jeden Montag 09:00: Claude schreibt Lob-Text pro Hund
- Push + Karte im Tagebuch
Monitoring:
- 4× täglich Status-Mail mit Scheduler-Status + Wiki-Fortschritt
This commit is contained in:
parent
65d1cf6c7f
commit
180de32e57
22 changed files with 4351 additions and 189 deletions
|
|
@ -11,6 +11,18 @@ window.Page_trainingsplaene = (() => {
|
|||
let _activePlan = 'welpe'; // welpe | junior | erwachsen
|
||||
let _activeAdultTab = 'grundkurs'; // grundkurs | aufbaukurs
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API HELPERS
|
||||
// ----------------------------------------------------------
|
||||
function _dogId() {
|
||||
return window.App?.state?.activeDogId || null;
|
||||
}
|
||||
|
||||
async function _apiGet(url) {
|
||||
const r = await fetch(url, {credentials: 'include'});
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HELPER
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -590,9 +602,151 @@ window.Page_trainingsplaene = (() => {
|
|||
</h2>
|
||||
${_renderPlanSelector()}
|
||||
${planContent}
|
||||
<!-- Trainingskalender -->
|
||||
<div id="tp-calendar-section" style="margin-top:var(--space-6)">
|
||||
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin:0 0 var(--space-3)">
|
||||
${_icon('calendar')} Trainingskalender
|
||||
</h3>
|
||||
<div id="tp-calendar-wrap" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);text-align:center;padding:var(--space-4)">
|
||||
Lade Kalender…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
_bindEvents();
|
||||
_loadCalendar();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// TRAININGSKALENDER
|
||||
// ----------------------------------------------------------
|
||||
async function _loadCalendar() {
|
||||
const dogId = _dogId();
|
||||
const wrap = _container.querySelector('#tp-calendar-wrap');
|
||||
if (!wrap) return;
|
||||
|
||||
if (!dogId) {
|
||||
wrap.innerHTML = `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);text-align:center;padding:var(--space-3)">Kein Hund ausgewählt.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const thisYear = now.getFullYear();
|
||||
const thisMon = now.getMonth() + 1; // 1-based
|
||||
|
||||
// Letzter Monat
|
||||
const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const prevYear = prevDate.getFullYear();
|
||||
const prevMon = prevDate.getMonth() + 1;
|
||||
|
||||
const [dataCurr, dataPrev] = await Promise.all([
|
||||
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${thisYear}&month=${thisMon}`),
|
||||
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${prevYear}&month=${prevMon}`),
|
||||
]);
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-4)">
|
||||
${_renderCalendarMonth(prevYear, prevMon, dataPrev?.days || {})}
|
||||
${_renderCalendarMonth(thisYear, thisMon, dataCurr?.days || {})}
|
||||
</div>
|
||||
<!-- Legende -->
|
||||
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-top:var(--space-2);
|
||||
font-size:var(--text-xs);color:var(--c-text-secondary);align-items:center">
|
||||
<span style="display:flex;align-items:center;gap:5px">
|
||||
<span style="width:14px;height:14px;border-radius:3px;border:1.5px solid var(--c-border);display:inline-block"></span>
|
||||
kein Training
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:5px">
|
||||
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary-subtle);border:1.5px solid var(--c-primary);display:inline-block"></span>
|
||||
Training
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:5px">
|
||||
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary);display:inline-block"></span>
|
||||
Top-Training
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderCalendarMonth(year, month, days) {
|
||||
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
|
||||
'Juli','August','September','Oktober','November','Dezember'];
|
||||
const WDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
|
||||
|
||||
const firstDay = new Date(year, month - 1, 1);
|
||||
// Monday = 0 offset: getDay() returns 0=Sun, so we shift
|
||||
let startOffset = firstDay.getDay(); // 0=Sun
|
||||
startOffset = startOffset === 0 ? 6 : startOffset - 1; // Mon=0 … Sun=6
|
||||
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
// Header Wochentage
|
||||
const wdayHeader = WDAYS.map(d =>
|
||||
`<div style="text-align:center;font-size:10px;font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);padding-bottom:4px">${d}</div>`
|
||||
).join('');
|
||||
|
||||
// Leere Slots am Anfang
|
||||
let cells = '';
|
||||
for (let i = 0; i < startOffset; i++) {
|
||||
cells += `<div></div>`;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
const dayData = days[dateStr];
|
||||
const isToday = dateStr === todayStr;
|
||||
|
||||
let bg = 'transparent';
|
||||
let border = '1.5px solid var(--c-border)';
|
||||
let color = 'var(--c-text-secondary)';
|
||||
let title = '';
|
||||
|
||||
if (dayData) {
|
||||
if (dayData.top) {
|
||||
bg = 'var(--c-primary)';
|
||||
border = '1.5px solid var(--c-primary)';
|
||||
color = '#fff';
|
||||
title = `Top-Training! ${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
|
||||
} else {
|
||||
bg = 'var(--c-primary-subtle)';
|
||||
border = '1.5px solid var(--c-primary)';
|
||||
color = 'var(--c-primary)';
|
||||
title = `${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
const todayRing = isToday ? 'box-shadow:0 0 0 2px var(--c-primary);' : '';
|
||||
|
||||
cells += `
|
||||
<div title="${title}"
|
||||
style="aspect-ratio:1;border-radius:4px;background:${bg};border:${border};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:10px;font-weight:${dayData ? 'var(--weight-semibold)' : '400'};
|
||||
color:${color};${todayRing}cursor:default">
|
||||
${d}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="flex:1;min-width:220px">
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin-bottom:var(--space-2)">
|
||||
${_esc(MONTHS[month - 1])} ${year}
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:3px">
|
||||
${wdayHeader}
|
||||
${cells}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue