Files
teatracker/app.js
Vibe Nuage Agent 58e2a70201 fix: Use window.app for onclick handlers on dynamically generated buttons
- Revert to onclick handlers for tea card edit/delete buttons
- Use window.app instead of app to ensure global accessibility
- Remove event delegation code that wasn't working
- Add onclick handlers to import modal buttons in HTML
- This ensures buttons work reliably on dynamically generated content

Co-authored-by: trevor1969 <trevor1969@users.noreply.github.com>
2026-06-04 15:06:42 +00:00

1564 lines
56 KiB
JavaScript

// TeeTracker - Main Application with Nextcloud Support
// ============================================
// Configuration
// ============================================
const STORAGE_KEY_TEAS = 'teatracker_teas';
const STORAGE_KEY_ENTRIES = 'teatracker_entries';
const STORAGE_KEY_NC_CONFIG = 'teatracker_nextcloud_config';
// ============================================
// Nextcloud Storage Integration
// ============================================
class NextcloudStorage {
constructor(baseUrl, username, password, path = '/TeeTracker/') {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.path = path.replace(/^\//, '/').replace(/\/$/, '/');
this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
this.connected = false;
this.lastError = null;
}
getFileUrl(filename) {
return `${this.baseUrl}/remote.php/dav/files/${encodeURIComponent(this.username)}${this.path}${filename}`;
}
getDirectoryUrl() {
return `${this.baseUrl}/remote.php/dav/files/${encodeURIComponent(this.username)}${this.path}`;
}
async testConnection() {
try {
const response = await fetch(this.getDirectoryUrl(), {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
'Content-Type': 'text/xml; charset=utf-8'
}
});
if (response.ok) {
this.connected = true;
this.lastError = null;
return true;
} else if (response.status === 404) {
await this.ensureDirectory();
this.connected = true;
this.lastError = null;
return true;
} else {
this.connected = false;
this.lastError = `HTTP Error: ${response.status}`;
return false;
}
} catch (error) {
this.connected = false;
this.lastError = error.message;
console.error('Nextcloud connection error:', error);
return false;
}
}
async ensureDirectory() {
try {
const response = await fetch(this.getDirectoryUrl(), {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader }
});
await this.ensureSubDirectory('backup');
return response.ok || response.status === 405;
} catch (error) {
console.error('Error creating directory:', error);
return false;
}
}
async ensureSubDirectory(subPath) {
try {
const subDirUrl = `${this.getDirectoryUrl()}${subPath}/`;
const response = await fetch(subDirUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader }
});
return response.ok || response.status === 405;
} catch (error) {
console.error('Error creating subdirectory:', error);
return false;
}
}
async loadFile(filename) {
try {
const url = this.getFileUrl(filename);
const response = await fetch(url, {
headers: { 'Authorization': this.authHeader }
});
if (response.ok) {
const text = await response.text();
return text ? JSON.parse(text) : null;
} else if (response.status === 404) {
return null;
}
return null;
} catch (error) {
console.error(`Error loading ${filename}:`, error);
return null;
}
}
async saveFile(filename, data) {
try {
const url = this.getFileUrl(filename);
const response = await fetch(url, {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(data, null, 2)
});
return response.ok;
} catch (error) {
console.error(`Error saving ${filename}:`, error);
return false;
}
}
async fileExists(filename) {
try {
const url = this.getFileUrl(filename);
const response = await fetch(url, {
method: 'HEAD',
headers: { 'Authorization': this.authHeader }
});
return response.ok;
} catch (error) {
return false;
}
}
async createBackup(backupName, teas, entries) {
try {
await this.ensureSubDirectory('backup');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupData = { timestamp, teas, entries };
const filename = `backup/${backupName}_${timestamp}.json`;
return await this.saveFile(filename, backupData);
} catch (error) {
console.error('Error creating backup:', error);
return false;
}
}
async restoreBackup(backupFilename) {
try {
const data = await this.loadFile(backupFilename);
if (data && data.teas && data.entries) {
return { teas: data.teas, entries: data.entries };
}
return null;
} catch (error) {
console.error('Error restoring backup:', error);
return null;
}
}
}
// ============================================
// Main TeeTracker Application
// ============================================
class TeeTracker {
constructor() {
// Storage configuration
this.useNextcloud = false;
this.nextcloudStorage = null;
this.syncInProgress = false;
// Data
this.teas = [];
this.entries = [];
this.currentTeaId = null;
this.charts = {};
// Initialize
this.initStorage();
this.loadData().then(() => {
this.init();
});
}
// ============================================
// Storage Initialization
// ============================================
initStorage() {
// Load Nextcloud configuration from localStorage
const ncConfig = localStorage.getItem(STORAGE_KEY_NC_CONFIG);
const ncPassword = sessionStorage.getItem(STORAGE_KEY_NC_CONFIG + '_password');
if (ncConfig) {
try {
const config = JSON.parse(ncConfig);
// Load password from sessionStorage (more secure than localStorage)
const password = ncPassword || '';
if (password) {
this.nextcloudStorage = new NextcloudStorage(
config.baseUrl,
config.username,
password,
config.path || '/TeeTracker/'
);
this.useNextcloud = true;
this.updateSyncStatus();
} else {
// Password not available, user needs to re-enter it
this.useNextcloud = false;
this.showStatusMessage('Bitte gib dein Nextcloud-Passwort erneut ein.', 'info');
}
} catch (error) {
console.error('Invalid Nextcloud config:', error);
this.useNextcloud = false;
}
} else {
// Don't pre-fill credentials - user must enter them manually
this.useNextcloud = false;
}
}
async loadData() {
if (this.useNextcloud && this.nextcloudStorage) {
// Try to load from Nextcloud first
await this.nextcloudStorage.ensureDirectory();
const teas = await this.nextcloudStorage.loadFile('teas.json');
const entries = await this.nextcloudStorage.loadFile('entries.json');
if (teas) {
this.teas = teas.map(tea => ({
...tea,
organic: tea.organic !== undefined ? tea.organic : false
}));
}
if (entries) {
this.entries = entries;
}
// If Nextcloud loading failed, fall back to localStorage
if (!teas || !entries) {
this.loadFromLocalStorage();
}
} else {
// Load from localStorage
this.loadFromLocalStorage();
}
}
loadFromLocalStorage() {
const teasData = localStorage.getItem(STORAGE_KEY_TEAS);
const entriesData = localStorage.getItem(STORAGE_KEY_ENTRIES);
if (teasData) {
try {
this.teas = JSON.parse(teasData).map(tea => ({
...tea,
organic: tea.organic !== undefined ? tea.organic : false
}));
} catch (error) {
this.teas = [];
}
}
if (entriesData) {
try {
this.entries = JSON.parse(entriesData);
} catch (error) {
this.entries = [];
}
}
}
async saveData() {
if (this.syncInProgress) return;
this.syncInProgress = true;
try {
if (this.useNextcloud && this.nextcloudStorage) {
await this.nextcloudStorage.ensureDirectory();
// Save to Nextcloud
const teasSaved = await this.nextcloudStorage.saveFile('teas.json', this.teas);
const entriesSaved = await this.nextcloudStorage.saveFile('entries.json', this.entries);
// Also save locally as backup
this.saveToLocalStorage();
if (teasSaved && entriesSaved) {
this.showToast('Daten erfolgreich mit Nextcloud synchronisiert!', 'success');
} else {
this.showToast('Lokale Speicherung erfolgreich, Nextcloud-Sync fehlgeschlagen', 'warning');
}
} else {
// Save only locally
this.saveToLocalStorage();
}
} catch (error) {
console.error('Error saving data:', error);
this.showToast('Fehler beim Speichern der Daten', 'error');
} finally {
this.syncInProgress = false;
}
}
saveToLocalStorage() {
localStorage.setItem(STORAGE_KEY_TEAS, JSON.stringify(this.teas));
localStorage.setItem(STORAGE_KEY_ENTRIES, JSON.stringify(this.entries));
}
updateSyncStatus() {
const statusBadge = document.getElementById('sync-status');
if (!statusBadge) return;
if (this.useNextcloud) {
if (this.nextcloudStorage && this.nextcloudStorage.connected) {
statusBadge.textContent = 'Verbunden mit Nextcloud';
statusBadge.className = 'status-badge connected';
} else {
statusBadge.textContent = 'Verbindung fehlgeschlagen';
statusBadge.className = 'status-badge disconnected';
}
} else {
statusBadge.textContent = 'Lokaler Modus';
statusBadge.className = 'status-badge local';
}
}
// ============================================
// Generate unique ID
// ============================================
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// ============================================
// Get data by ID
// ============================================
getTeaById(id) {
return this.teas.find(tea => tea.id === id);
}
getTeaIndexById(id) {
return this.teas.findIndex(tea => tea.id === id);
}
// ============================================
// Tea Management
// ============================================
addTea(tea) {
const newTea = {
id: this.generateId(),
name: tea.name,
type: tea.type,
brand: tea.brand || '',
description: tea.description || '',
color: tea.color || '#8B4513',
organic: tea.organic || false,
createdAt: new Date().toISOString()
};
this.teas.push(newTea);
this.saveData();
return newTea;
}
updateTea(id, updates) {
const index = this.getTeaIndexById(id);
if (index !== -1) {
this.teas[index] = { ...this.teas[index], ...updates };
this.saveData();
return this.teas[index];
}
return null;
}
deleteTea(id) {
const index = this.getTeaIndexById(id);
if (index !== -1) {
this.entries = this.entries.filter(entry => entry.teaId !== id);
this.teas.splice(index, 1);
this.saveData();
return true;
}
return false;
}
// ============================================
// Entry Management
// ============================================
addEntry(entry) {
const newEntry = {
id: this.generateId(),
teaId: entry.teaId,
date: entry.date,
time: entry.time || '',
amount: parseInt(entry.amount) || 1,
notes: entry.notes || '',
createdAt: new Date().toISOString()
};
this.entries.push(newEntry);
this.saveData();
return newEntry;
}
deleteEntry(id) {
const index = this.entries.findIndex(entry => entry.id === id);
if (index !== -1) {
this.entries.splice(index, 1);
this.saveData();
return true;
}
return false;
}
// ============================================
// Statistics
// ============================================
getTotalCups() {
return this.entries.reduce((sum, entry) => sum + entry.amount, 0);
}
getTodayCups() {
const today = new Date().toISOString().split('T')[0];
return this.entries
.filter(entry => entry.date === today)
.reduce((sum, entry) => sum + entry.amount, 0);
}
getWeekCups() {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return this.entries
.filter(entry => new Date(entry.date) >= weekAgo)
.reduce((sum, entry) => sum + entry.amount, 0);
}
getEntriesByPeriod(period) {
const now = new Date();
let startDate;
switch (period) {
case 'today':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
break;
case 'year':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
case 'all':
startDate = new Date(0);
break;
default:
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
}
return this.entries.filter(entry => new Date(entry.date) >= startDate);
}
getCupsByPeriod(period) {
const entries = this.getEntriesByPeriod(period);
return entries.reduce((sum, entry) => sum + entry.amount, 0);
}
getCupsByTeaType(period = 'all') {
const entries = this.getEntriesByPeriod(period);
const typeCounts = {};
entries.forEach(entry => {
const tea = this.getTeaById(entry.teaId);
if (tea) {
typeCounts[tea.type] = (typeCounts[tea.type] || 0) + entry.amount;
}
});
return typeCounts;
}
getCupsByTea(period = 'all') {
const entries = this.getEntriesByPeriod(period);
const teaCounts = {};
entries.forEach(entry => {
const tea = this.getTeaById(entry.teaId);
if (tea) {
teaCounts[tea.name] = (teaCounts[tea.name] || 0) + entry.amount;
}
});
return teaCounts;
}
getDailyConsumption(period = 'week') {
const entries = this.getEntriesByPeriod(period);
const dailyCounts = {};
entries.forEach(entry => {
const date = entry.date;
dailyCounts[date] = (dailyCounts[date] || 0) + entry.amount;
});
return dailyCounts;
}
getMostConsumedTea(period = 'all') {
const teaCounts = this.getCupsByTea(period);
let maxCount = 0;
let mostConsumed = null;
for (const [teaName, count] of Object.entries(teaCounts)) {
if (count > maxCount) {
maxCount = count;
mostConsumed = teaName;
}
}
return { name: mostConsumed, count: maxCount };
}
countUniqueDays(entries) {
const days = new Set();
entries.forEach(entry => days.add(entry.date));
return days.size;
}
// ============================================
// UI Initialization
// ============================================
init() {
this.setupEventListeners();
this.renderAll();
this.initCharts();
this.updateSettingsStats();
}
setupEventListeners() {
// Tab navigation
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
});
// Add tea button
if (document.getElementById('add-tea-btn')) {
document.getElementById('add-tea-btn').addEventListener('click', () => this.openTeaModal());
}
// Tea modal
if (document.getElementById('close-modal')) {
document.getElementById('close-modal').addEventListener('click', () => this.closeTeaModal());
}
if (document.getElementById('tea-form')) {
document.getElementById('tea-form').addEventListener('submit', (e) => this.handleTeaFormSubmit(e));
}
if (document.getElementById('delete-tea-btn')) {
document.getElementById('delete-tea-btn').addEventListener('click', () => this.handleDeleteTea());
}
// Confirm modal
if (document.getElementById('close-confirm')) {
document.getElementById('close-confirm').addEventListener('click', () => this.closeConfirmModal());
}
if (document.getElementById('confirm-no')) {
document.getElementById('confirm-no').addEventListener('click', () => this.closeConfirmModal());
}
if (document.getElementById('confirm-yes')) {
document.getElementById('confirm-yes').addEventListener('click', () => this.confirmDelete());
}
// Track form
if (document.getElementById('track-form')) {
document.getElementById('track-form').addEventListener('submit', (e) => this.handleTrackFormSubmit(e));
}
if (document.getElementById('cancel-track')) {
document.getElementById('cancel-track').addEventListener('click', () => this.resetTrackForm());
}
// Tea search and filter
if (document.getElementById('tea-search')) {
document.getElementById('tea-search').addEventListener('input', (e) => this.filterTeas());
}
if (document.getElementById('tea-type-filter')) {
document.getElementById('tea-type-filter').addEventListener('change', (e) => this.filterTeas());
}
// Stats period filter
if (document.getElementById('stats-period')) {
document.getElementById('stats-period').addEventListener('change', (e) => this.updateStats());
}
// Close modals on outside click
if (document.getElementById('tea-modal')) {
document.getElementById('tea-modal').addEventListener('click', (e) => {
if (e.target.id === 'tea-modal') this.closeTeaModal();
});
}
if (document.getElementById('confirm-modal')) {
document.getElementById('confirm-modal').addEventListener('click', (e) => {
if (e.target.id === 'confirm-modal') this.closeConfirmModal();
});
}
// Nextcloud settings
this.setupNextcloudListeners();
}
setupNextcloudListeners() {
// Toggle Nextcloud config visibility
const ncEnabled = document.getElementById('nc-enabled');
const ncConfig = document.getElementById('nc-config');
if (ncEnabled && ncConfig) {
ncEnabled.addEventListener('change', () => {
ncConfig.style.display = ncEnabled.checked ? 'block' : 'none';
});
// Load saved config
const config = localStorage.getItem(STORAGE_KEY_NC_CONFIG);
if (config) {
try {
const ncConfigData = JSON.parse(config);
ncEnabled.checked = true;
ncConfig.style.display = 'block';
document.getElementById('nc-url').value = ncConfigData.baseUrl || '';
document.getElementById('nc-username').value = ncConfigData.username || '';
document.getElementById('nc-password').value = ncConfigData.password || '';
document.getElementById('nc-path').value = ncConfigData.path || '/TeeTracker/';
} catch (error) {
console.error('Error loading NC config:', error);
}
}
}
// Test connection button
const testBtn = document.getElementById('test-nc-connection');
if (testBtn) {
testBtn.addEventListener('click', async () => {
await this.testNextcloudConnection();
});
}
// Save config button
const saveBtn = document.getElementById('save-nc-config');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
await this.saveNextcloudConfig();
});
}
// Export data button
const exportBtn = document.getElementById('export-data');
if (exportBtn) {
exportBtn.addEventListener('click', () => this.exportData());
}
// Import data button
const importBtn = document.getElementById('import-data');
if (importBtn) {
importBtn.addEventListener('click', () => this.openImportModal());
}
// Close import modal button
const closeImportBtn = document.getElementById('close-import-modal');
if (closeImportBtn) {
closeImportBtn.addEventListener('click', () => this.closeImportModal());
}
// Cancel import button
const cancelImportBtn = document.getElementById('cancel-import-btn');
if (cancelImportBtn) {
cancelImportBtn.addEventListener('click', () => this.closeImportModal());
}
// Import data button in modal
const importDataBtn = document.getElementById('import-data-btn');
if (importDataBtn) {
importDataBtn.addEventListener('click', () => this.handleImportData());
}
// Close import modal on outside click
const importModal = document.getElementById('import-modal');
if (importModal) {
importModal.addEventListener('click', (e) => {
if (e.target.id === 'import-modal') this.closeImportModal();
});
}
// Clear data button
const clearBtn = document.getElementById('clear-data');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.showClearDataConfirm());
}
}
// ============================================
// Nextcloud Functions
// ============================================
async testNextcloudConnection() {
const statusElement = document.getElementById('nc-status');
if (!statusElement) return;
const url = document.getElementById('nc-url').value;
const username = document.getElementById('nc-username').value;
const password = document.getElementById('nc-password').value;
if (!url || !username || !password) {
this.showStatusMessage('Bitte fülle alle Pflichtfelder aus!', 'error');
return;
}
this.showStatusMessage('Verbindung wird getestet...', 'info');
try {
const ncStorage = new NextcloudStorage(url, username, password);
const connected = await ncStorage.testConnection();
if (connected) {
this.showStatusMessage('✅ Verbindung erfolgreich! Nextcloud ist erreichbar.', 'success');
} else {
this.showStatusMessage(`❌ Verbindung fehlgeschlagen: ${ncStorage.lastError}`, 'error');
}
} catch (error) {
this.showStatusMessage(`❌ Fehler: ${error.message}`, 'error');
}
}
async saveNextcloudConfig() {
const statusElement = document.getElementById('nc-status');
if (!statusElement) return;
const enabled = document.getElementById('nc-enabled').checked;
const url = document.getElementById('nc-url').value;
const username = document.getElementById('nc-username').value;
const password = document.getElementById('nc-password').value;
const path = document.getElementById('nc-path').value;
if (enabled && (!url || !username || !password)) {
this.showStatusMessage('Bitte fülle alle Pflichtfelder aus!', 'error');
return;
}
this.showStatusMessage('Konfiguration wird gespeichert...', 'info');
try {
if (enabled) {
// Test connection first
const ncStorage = new NextcloudStorage(url, username, password, path);
const connected = await ncStorage.testConnection();
if (!connected) {
this.showStatusMessage(`❌ Verbindung fehlgeschlagen: ${ncStorage.lastError}`, 'error');
return;
}
// Save config WITHOUT password for security
// Password will be requested each time or stored in sessionStorage
const config = { baseUrl: url, username, path };
localStorage.setItem(STORAGE_KEY_NC_CONFIG, JSON.stringify(config));
// Store password in sessionStorage (cleared when browser closes)
sessionStorage.setItem(STORAGE_KEY_NC_CONFIG + '_password', password);
// Update app state
this.nextcloudStorage = ncStorage;
this.useNextcloud = true;
// Save current data to Nextcloud
await this.saveData();
this.showStatusMessage('✅ Konfiguration gespeichert und Daten synchronisiert!', 'success');
} else {
// Disable Nextcloud
localStorage.removeItem(STORAGE_KEY_NC_CONFIG);
sessionStorage.removeItem(STORAGE_KEY_NC_CONFIG + '_password');
this.useNextcloud = false;
this.nextcloudStorage = null;
this.showStatusMessage('✅ Nextcloud-Synchronisation deaktiviert. Daten werden lokal gespeichert.', 'success');
}
this.updateSyncStatus();
this.updateSettingsStats();
} catch (error) {
this.showStatusMessage(`❌ Fehler: ${error.message}`, 'error');
}
}
showStatusMessage(message, type = 'info') {
const statusElement = document.getElementById('nc-status');
if (statusElement) {
statusElement.textContent = message;
statusElement.className = `status-message ${type}`;
}
}
// ============================================
// Data Export/Import
// ============================================
exportData() {
const data = {
teas: this.teas,
entries: this.entries,
exportedAt: new Date().toISOString()
};
const dataStr = JSON.stringify(data, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `teatracker_export_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
this.showToast('Daten wurden exportiert!', 'success');
}
openImportModal() {
const modal = document.getElementById('import-modal');
if (modal) {
document.getElementById('import-data-textarea').value = '';
modal.classList.add('active');
}
}
async handleImportData() {
const textarea = document.getElementById('import-data-textarea');
if (!textarea) return;
try {
const data = JSON.parse(textarea.value);
if (data.teas && data.entries) {
// Merge or replace data
if (confirm('Sollen die importierten Daten die bestehenden ersetzen?')) {
this.teas = data.teas.map(tea => ({
...tea,
organic: tea.organic !== undefined ? tea.organic : false
}));
this.entries = data.entries;
} else {
// Merge data
this.teas = [...this.teas, ...data.teas.map(tea => ({
...tea,
organic: tea.organic !== undefined ? tea.organic : false
}))];
this.entries = [...this.entries, ...data.entries];
}
await this.saveData();
this.closeImportModal();
this.showToast('Daten wurden erfolgreich importiert!', 'success');
this.renderAll();
this.updateSettingsStats();
} else {
this.showToast('Ungültiges Datenformat!', 'error');
}
} catch (error) {
this.showToast('Fehler beim Importieren der Daten!', 'error');
}
}
closeImportModal() {
const modal = document.getElementById('import-modal');
if (modal) {
modal.classList.remove('active');
}
}
showClearDataConfirm() {
const message = 'Möchtest du wirklich ALLE Daten löschen? Dieser Vorgang kann nicht rückgängig gemacht werden!';
document.getElementById('confirm-message').textContent = message;
document.getElementById('confirm-modal').classList.add('active');
// Override confirm function temporarily
this.tempConfirmAction = 'clearData';
}
async confirmClearData() {
this.teas = [];
this.entries = [];
// Clear both localStorage and Nextcloud
localStorage.removeItem(STORAGE_KEY_TEAS);
localStorage.removeItem(STORAGE_KEY_ENTRIES);
if (this.useNextcloud && this.nextcloudStorage) {
await this.nextcloudStorage.saveFile('teas.json', []);
await this.nextcloudStorage.saveFile('entries.json', []);
}
this.closeConfirmModal();
this.showToast('Alle Daten wurden gelöscht!', 'success');
this.renderAll();
this.updateSettingsStats();
}
// ============================================
// Tab Navigation
// ============================================
switchTab(tabId) {
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.toggle('active', button.dataset.tab === tabId);
});
document.querySelectorAll('.tab-pane').forEach(pane => {
pane.classList.toggle('active', pane.id === tabId);
});
if (tabId === 'teas') {
this.renderTeasList();
} else if (tabId === 'track') {
this.renderTrackForm();
} else if (tabId === 'stats') {
this.updateStats();
} else if (tabId === 'dashboard') {
this.updateDashboard();
} else if (tabId === 'settings') {
this.updateSettingsStats();
}
}
renderAll() {
this.updateDashboard();
this.renderTeasList();
this.renderTrackForm();
this.updateStats();
this.updateSettingsStats();
}
updateSettingsStats() {
const totalCups = document.getElementById('settings-total-cups');
const totalTeas = document.getElementById('settings-total-teas');
const firstEntry = document.getElementById('settings-first-entry');
const lastEntry = document.getElementById('settings-last-entry');
if (totalCups) totalCups.textContent = this.getTotalCups();
if (totalTeas) totalTeas.textContent = this.teas.length;
if (firstEntry) {
if (this.entries.length > 0) {
const first = this.entries.reduce((a, b) => new Date(a.createdAt) < new Date(b.createdAt) ? a : b);
firstEntry.textContent = new Date(first.createdAt).toLocaleDateString('de-DE');
} else {
firstEntry.textContent = '-';
}
}
if (lastEntry) {
if (this.entries.length > 0) {
const last = this.entries.reduce((a, b) => new Date(a.createdAt) > new Date(b.createdAt) ? a : b);
lastEntry.textContent = new Date(last.createdAt).toLocaleDateString('de-DE');
} else {
lastEntry.textContent = '-';
}
}
}
// ============================================
// Dashboard
// ============================================
updateDashboard() {
const totalCups = document.getElementById('total-cups');
const todayCups = document.getElementById('today-cups');
const weekCups = document.getElementById('week-cups');
const totalTeas = document.getElementById('total-teas');
if (totalCups) totalCups.textContent = `${this.getTotalCups()} Tassen`;
if (todayCups) todayCups.textContent = `${this.getTodayCups()} Tassen`;
if (weekCups) weekCups.textContent = `${this.getWeekCups()} Tassen`;
if (totalTeas) totalTeas.textContent = `${this.teas.length} Sorten`;
this.renderRecentEntries();
}
renderRecentEntries() {
const container = document.getElementById('recent-entries');
if (!container) return;
const recentEntries = [...this.entries]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 5);
if (recentEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📝</div>
<div class="empty-state-text">Keine Einträge vorhanden</div>
<div class="empty-state-subtext">Fange an, deinen Tee-Konsum zu tracken!</div>
</div>
`;
return;
}
container.innerHTML = recentEntries.map(entry => {
const tea = this.getTeaById(entry.teaId);
const date = new Date(entry.date);
const formattedDate = date.toLocaleDateString('de-DE');
const formattedTime = entry.time ? entry.time.substring(0, 5) : '';
return `
<div class="activity-item">
<div class="activity-info">
<div class="activity-tea">${tea ? tea.name : 'Unbekannter Tee'} ${tea && tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</div>
<div class="activity-details">${formattedDate} ${formattedTime ? `um ${formattedTime}` : ''}</div>
</div>
<div class="activity-amount">${entry.amount} Tasse${entry.amount > 1 ? 'n' : ''}</div>
</div>
`;
}).join('');
}
// ============================================
// Tea Management UI
// ============================================
renderTeasList() {
const container = document.getElementById('teas-list');
if (!container) return;
if (this.teas.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🍵</div>
<div class="empty-state-text">Keine Tee-Sorten vorhanden</div>
<div class="empty-state-subtext">Klicke auf "Tee hinzufügen", um deine erste Sorte anzulegen</div>
</div>
`;
return;
}
container.innerHTML = this.teas.map(tea => `
<div class="tea-card" data-id="${tea.id}">
<div class="tea-card-header">
<div>
<h3 class="tea-name">${tea.name} ${tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</h3>
<span class="tea-type">${tea.type}</span>
</div>
</div>
${tea.brand ? `<div class="tea-brand">Marke: ${tea.brand}</div>` : ''}
${tea.description ? `<div class="tea-description">${tea.description}</div>` : ''}
<div class="tea-actions">
<button class="tea-action-btn tea-edit-btn" onclick="window.app.editTea('${tea.id}')">Bearbeiten</button>
<button class="tea-action-btn tea-delete-btn" onclick="window.app.showDeleteTeaConfirm('${tea.id}')">Löschen</button>
</div>
</div>
`).join('');
}
filterTeas() {
const searchTerm = document.getElementById('tea-search').value.toLowerCase();
const typeFilter = document.getElementById('tea-type-filter').value;
const filteredTeas = this.teas.filter(tea => {
const matchesSearch = tea.name.toLowerCase().includes(searchTerm) ||
tea.brand.toLowerCase().includes(searchTerm) ||
tea.description.toLowerCase().includes(searchTerm);
const matchesType = typeFilter === 'all' || tea.type === typeFilter;
return matchesSearch && matchesType;
});
const container = document.getElementById('teas-list');
if (!container) return;
if (filteredTeas.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<div class="empty-state-text">Keine Tee-Sorten gefunden</div>
<div class="empty-state-subtext">Versuche andere Suchbegriffe oder Filter</div>
</div>
`;
return;
}
container.innerHTML = filteredTeas.map(tea => `
<div class="tea-card" data-id="${tea.id}">
<div class="tea-card-header">
<div>
<h3 class="tea-name">${tea.name} ${tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</h3>
<span class="tea-type">${tea.type}</span>
</div>
</div>
${tea.brand ? `<div class="tea-brand">Marke: ${tea.brand}</div>` : ''}
${tea.description ? `<div class="tea-description">${tea.description}</div>` : ''}
<div class="tea-actions">
<button class="tea-action-btn tea-edit-btn" onclick="window.app.editTea('${tea.id}')">Bearbeiten</button>
<button class="tea-action-btn tea-delete-btn" onclick="window.app.showDeleteTeaConfirm('${tea.id}')">Löschen</button>
</div>
</div>
`).join('');
}
openTeaModal(teaId = null) {
const modal = document.getElementById('tea-modal');
const form = document.getElementById('tea-form');
const deleteBtn = document.getElementById('delete-tea-btn');
if (!modal || !form) return;
if (teaId) {
const tea = this.getTeaById(teaId);
if (tea) {
document.getElementById('modal-title').textContent = 'Tee bearbeiten';
document.getElementById('tea-id').value = tea.id;
document.getElementById('tea-name').value = tea.name;
document.getElementById('tea-type').value = tea.type;
document.getElementById('tea-brand').value = tea.brand || '';
document.getElementById('tea-description').value = tea.description || '';
document.getElementById('tea-color').value = tea.color || '#8B4513';
document.getElementById('tea-organic').checked = tea.organic || false;
deleteBtn.style.display = 'inline-flex';
this.currentTeaId = tea.id;
}
} else {
document.getElementById('modal-title').textContent = 'Tee hinzufügen';
form.reset();
document.getElementById('tea-id').value = '';
deleteBtn.style.display = 'none';
this.currentTeaId = null;
}
modal.classList.add('active');
}
closeTeaModal() {
const modal = document.getElementById('tea-modal');
if (modal) {
modal.classList.remove('active');
const form = document.getElementById('tea-form');
if (form) form.reset();
this.currentTeaId = null;
}
}
handleTeaFormSubmit(e) {
e.preventDefault();
const formData = {
name: document.getElementById('tea-name').value.trim(),
type: document.getElementById('tea-type').value,
brand: document.getElementById('tea-brand').value.trim(),
description: document.getElementById('tea-description').value.trim(),
color: document.getElementById('tea-color').value,
organic: document.getElementById('tea-organic').checked
};
const teaId = document.getElementById('tea-id').value;
if (teaId) {
this.updateTea(teaId, formData);
this.showToast('Tee erfolgreich aktualisiert!', 'success');
} else {
this.addTea(formData);
this.showToast('Tee erfolgreich hinzugefügt!', 'success');
}
this.closeTeaModal();
this.renderTeasList();
this.renderTrackForm();
this.updateDashboard();
this.updateStats();
this.updateSettingsStats();
}
editTea(teaId) {
this.openTeaModal(teaId);
}
showDeleteTeaConfirm(teaId) {
this.currentTeaId = teaId;
const tea = this.getTeaById(teaId);
if (tea) {
document.getElementById('confirm-message').textContent =
`Möchtest du den Tee "${tea.name}" wirklich löschen? Alle zugehörigen Einträge werden ebenfalls gelöscht.`;
document.getElementById('confirm-modal').classList.add('active');
this.tempConfirmAction = 'deleteTea';
}
}
handleDeleteTea() {
if (this.currentTeaId) {
const tea = this.getTeaById(this.currentTeaId);
if (tea) {
document.getElementById('confirm-message').textContent =
`Möchtest du den Tee "${tea.name}" wirklich löschen? Alle zugehörigen Einträge werden ebenfalls gelöscht.`;
document.getElementById('confirm-modal').classList.add('active');
this.tempConfirmAction = 'deleteTea';
}
}
}
confirmDelete() {
if (this.tempConfirmAction === 'deleteTea' && this.currentTeaId) {
const tea = this.getTeaById(this.currentTeaId);
if (tea) {
this.deleteTea(this.currentTeaId);
this.showToast(`Tee "${tea.name}" wurde gelöscht.`, 'success');
this.closeConfirmModal();
this.closeTeaModal();
this.renderTeasList();
this.renderTrackForm();
this.updateDashboard();
this.updateStats();
this.updateSettingsStats();
}
} else if (this.tempConfirmAction === 'clearData') {
this.confirmClearData();
}
this.tempConfirmAction = null;
}
closeConfirmModal() {
const modal = document.getElementById('confirm-modal');
if (modal) {
modal.classList.remove('active');
}
this.tempConfirmAction = null;
this.currentTeaId = null;
}
// ============================================
// Track Form
// ============================================
renderTrackForm() {
const teaSelect = document.getElementById('track-tea');
const today = new Date().toISOString().split('T')[0];
if (!teaSelect) return;
document.getElementById('track-date').value = today;
if (this.teas.length === 0) {
teaSelect.innerHTML = '<option value="">-- Keine Tee-Sorten verfügbar --</option>';
} else {
teaSelect.innerHTML = '<option value="">-- Wähle eine Tee-Sorte --</option>' +
this.teas.map(tea =>
`<option value="${tea.id}">${tea.name} (${tea.type})${tea.organic ? ' 🌱' : ''}</option>`
).join('');
}
this.renderTrackRecentEntries();
}
handleTrackFormSubmit(e) {
e.preventDefault();
const teaId = document.getElementById('track-tea').value;
const date = document.getElementById('track-date').value;
const time = document.getElementById('track-time').value;
const amount = document.getElementById('track-amount').value;
const notes = document.getElementById('track-notes').value.trim();
if (!teaId || !date || !amount) {
this.showToast('Bitte fülle alle Pflichtfelder aus!', 'error');
return;
}
const entry = {
teaId,
date,
time,
amount,
notes
};
this.addEntry(entry);
this.showToast('Tee-Konsum erfolgreich getrackt!', 'success');
this.resetTrackForm();
this.updateDashboard();
this.updateStats();
this.renderTrackRecentEntries();
this.updateSettingsStats();
}
resetTrackForm() {
const form = document.getElementById('track-form');
if (form) {
form.reset();
const today = new Date().toISOString().split('T')[0];
document.getElementById('track-date').value = today;
document.getElementById('track-amount').value = 1;
}
}
renderTrackRecentEntries() {
const container = document.getElementById('track-recent-entries');
if (!container) return;
const recentEntries = [...this.entries]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 10);
if (recentEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📝</div>
<div class="empty-state-text">Keine Einträge vorhanden</div>
<div class="empty-state-subtext">Fange an, deinen Tee-Konsum zu tracken!</div>
</div>
`;
return;
}
container.innerHTML = recentEntries.map(entry => {
const tea = this.getTeaById(entry.teaId);
const date = new Date(entry.date);
const formattedDate = date.toLocaleDateString('de-DE');
const formattedTime = entry.time ? entry.time.substring(0, 5) : '';
return `
<div class="activity-item">
<div class="activity-info">
<div class="activity-tea">${tea ? tea.name : 'Unbekannter Tee'} ${tea && tea.organic ? '<span class="organic-badge" title="Bio-Tee">🌱</span>' : ''}</div>
<div class="activity-details">${formattedDate} ${formattedTime ? `um ${formattedTime}` : ''}</div>
</div>
<div class="activity-amount">${entry.amount} Tasse${entry.amount > 1 ? 'n' : ''}</div>
</div>
`;
}).join('');
}
// ============================================
// Statistics
// ============================================
initCharts() {
this.createTeaTypeChart();
this.createDailyChart();
}
createTeaTypeChart() {
const ctx = document.getElementById('tea-type-chart');
if (!ctx) return;
const period = document.getElementById('stats-period').value || 'week';
const typeCounts = this.getCupsByTeaType(period);
const labels = Object.keys(typeCounts);
const data = Object.values(typeCounts);
const backgroundColors = labels.map((type, index) => {
const colors = [
'#8B4513', '#228B22', '#90EE90', '#FFD700',
'#FF6347', '#FF69B4', '#4169E1', '#8A2BE2'
];
return colors[index % colors.length];
});
if (this.charts.teaType) {
this.charts.teaType.destroy();
}
this.charts.teaType = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: backgroundColors,
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
padding: 15,
font: { size: 12 }
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
return `${label}: ${value} Tasse${value > 1 ? 'n' : ''}`;
}
}
}
},
layout: { padding: 20 }
}
});
}
createDailyChart() {
const ctx = document.getElementById('daily-chart');
if (!ctx) return;
const period = document.getElementById('stats-period').value || 'week';
const dailyCounts = this.getDailyConsumption(period);
const sortedDates = Object.keys(dailyCounts).sort();
const labels = sortedDates.map(date => {
const d = new Date(date);
return period === 'year'
? d.toLocaleDateString('de-DE', { month: 'short', day: 'numeric' })
: d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' });
});
const data = sortedDates.map(date => dailyCounts[date]);
if (this.charts.daily) {
this.charts.daily.destroy();
}
this.charts.daily = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Tassen pro Tag',
data: data,
backgroundColor: 'rgba(139, 69, 19, 0.7)',
borderColor: 'rgba(139, 69, 19, 1)',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return `${context.raw} Tasse${context.raw > 1 ? 'n' : ''}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
callback: function(value) {
return value + (value === 1 ? ' Tasse' : ' Tassen');
}
}
}
},
layout: { padding: 20 }
}
});
}
updateStats() {
const period = document.getElementById('stats-period').value || 'week';
this.createTeaTypeChart();
this.createDailyChart();
this.renderDetailedStats(period);
}
renderDetailedStats(period) {
const container = document.getElementById('stats-details');
if (!container) return;
const entries = this.getEntriesByPeriod(period);
const totalCups = entries.reduce((sum, entry) => sum + entry.amount, 0);
const uniqueDays = this.countUniqueDays(entries);
const avgDaily = uniqueDays > 0 ? Math.round(totalCups / uniqueDays) : 0;
const mostConsumed = this.getMostConsumedTea(period);
const teaCount = new Set(entries.map(entry => entry.teaId)).size;
container.innerHTML = `
<div class="detail-item">
<div class="detail-item-label">Gesamt getrunken</div>
<div class="detail-item-value">${totalCups} Tassen</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Durchschnitt pro Tag</div>
<div class="detail-item-value">${avgDaily} Tassen</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Aktive Tage</div>
<div class="detail-item-value">${uniqueDays} Tage</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Verschiedene Tees</div>
<div class="detail-item-value">${teaCount} Sorten</div>
</div>
${mostConsumed.name ? `
<div class="detail-item">
<div class="detail-item-label">Meist getrunken</div>
<div class="detail-item-value">${mostConsumed.name}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Favorit (Anzahl)</div>
<div class="detail-item-value">${mostConsumed.count} Tassen</div>
</div>
` : ''}
`;
}
// ============================================
// Utility Functions
// ============================================
showToast(message, type = 'info') {
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toastSlideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
// Initialize the application
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new TeeTracker();
window.app = app;
});