// 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'; // Data directory for Nextcloud storage const DATA_DIRECTORY = 'data/'; // ============================================ // 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; } } // Migrate old files from root to data/ directory async migrateToDataDirectory() { try { // Check if old teas.json exists in root const oldTeasUrl = this.getFileUrl('teas.json'); const oldTeasResponse = await fetch(oldTeasUrl, { headers: { 'Authorization': this.authHeader } }); if (oldTeasResponse.ok) { const oldTeas = await oldTeasResponse.json(); await this.saveFile(DATA_DIRECTORY + 'teas.json', oldTeas); console.log('Migrated teas.json to data/ directory'); } // Check if old entries.json exists in root const oldEntriesUrl = this.getFileUrl('entries.json'); const oldEntriesResponse = await fetch(oldEntriesUrl, { headers: { 'Authorization': this.authHeader } }); if (oldEntriesResponse.ok) { const oldEntries = await oldEntriesResponse.json(); await this.saveFile(DATA_DIRECTORY + 'entries.json', oldEntries); console.log('Migrated entries.json to data/ directory'); } } catch (error) { console.log('No old files to migrate or migration failed:', error.message); } } async ensureDirectory() { try { const response = await fetch(this.getDirectoryUrl(), { method: 'MKCOL', headers: { 'Authorization': this.authHeader } }); await this.ensureSubDirectory('data'); 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('data'); 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 = {}; this.currentBgImage = null; // 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(); // Check if old files exist in root directory and migrate to data/ directory await this.migrateToDataDirectory(); const teas = await this.nextcloudStorage.loadFile(DATA_DIRECTORY + 'teas.json'); const entries = await this.nextcloudStorage.loadFile(DATA_DIRECTORY + 'entries.json'); if (teas) { this.teas = teas.map(tea => ({ ...tea, organic: tea.organic !== undefined ? tea.organic : false, rating: tea.rating !== undefined ? tea.rating : 3 })); } if (entries) { this.entries = entries.map(entry => ({ ...entry, teaspoons: entry.teaspoons !== undefined ? entry.teaspoons : 1 })); } // 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, rating: tea.rating !== undefined ? tea.rating : 3 })); } catch (error) { this.teas = []; } } if (entriesData) { try { this.entries = JSON.parse(entriesData).map(entry => ({ ...entry, teaspoons: entry.teaspoons !== undefined ? entry.teaspoons : 1 })); } 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(DATA_DIRECTORY + 'teas.json', this.teas); const entriesSaved = await this.nextcloudStorage.saveFile(DATA_DIRECTORY + '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, rating: tea.rating || 3, image: tea.image || '', 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, teaspoons: entry.teaspoons ? parseFloat(entry.teaspoons) : 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()); } // Star rating for tea form const teaRating = document.getElementById('tea-rating'); if (teaRating) { teaRating.addEventListener('click', (e) => this.handleStarRatingClick(e)); } // Tea image upload const teaImageUpload = document.getElementById('tea-image-upload'); if (teaImageUpload) { teaImageUpload.addEventListener('change', (e) => this.handleImageUpload(e)); } // 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(); }); } // Background image settings this.setupBackgroundListeners(); // 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()); } } setupBackgroundListeners() { // Background image upload const bgUpload = document.getElementById('bg-image-upload'); if (bgUpload) { bgUpload.addEventListener('change', (e) => this.handleBackgroundUpload(e)); } // Background enable toggle const bgEnabled = document.getElementById('bg-enabled'); if (bgEnabled) { bgEnabled.addEventListener('change', () => this.toggleBackground()); } // Save background button const saveBgBtn = document.getElementById('save-bg'); if (saveBgBtn) { saveBgBtn.addEventListener('click', () => this.saveBackgroundSettings()); } // Remove background button const removeBgBtn = document.getElementById('remove-bg'); if (removeBgBtn) { removeBgBtn.addEventListener('click', () => this.removeBackground()); } // Load saved background settings this.loadBackgroundSettings(); } handleBackgroundUpload(e) { const file = e.target.files[0]; if (!file) return; const preview = document.getElementById('bg-preview-img'); const previewContainer = document.getElementById('bg-image-preview'); const removeBtn = document.getElementById('remove-bg'); if (file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = (event) => { preview.src = event.target.result; previewContainer.style.display = 'block'; removeBtn.style.display = 'inline-flex'; this.currentBgImage = event.target.result; }; reader.readAsDataURL(file); } } toggleBackground() { const enabled = document.getElementById('bg-enabled').checked; if (enabled && this.currentBgImage) { document.documentElement.style.setProperty('--bg-image', `url("${this.currentBgImage}")`); } else { document.documentElement.style.setProperty('--bg-image', 'none'); } } saveBackgroundSettings() { const enabled = document.getElementById('bg-enabled').checked; const bgImage = this.currentBgImage; if (enabled && bgImage) { localStorage.setItem('teatracker_bg_enabled', 'true'); localStorage.setItem('teatracker_bg_image', bgImage); this.showToast('Hintergrundbild gespeichert!', 'success'); } else { localStorage.removeItem('teatracker_bg_enabled'); localStorage.removeItem('teatracker_bg_image'); this.showToast('Hintergrundbild deaktiviert!', 'success'); } this.toggleBackground(); } removeBackground() { this.currentBgImage = null; document.getElementById('bg-preview-img').src = ''; document.getElementById('bg-image-preview').style.display = 'none'; document.getElementById('remove-bg').style.display = 'none'; document.getElementById('bg-enabled').checked = false; this.toggleBackground(); } loadBackgroundSettings() { this.currentBgImage = localStorage.getItem('teatracker_bg_image'); const enabled = localStorage.getItem('teatracker_bg_enabled') === 'true'; if (this.currentBgImage) { document.getElementById('bg-enabled').checked = enabled; if (enabled) { document.documentElement.style.setProperty('--bg-image', `url("${this.currentBgImage}")`); } // Show preview const preview = document.getElementById('bg-preview-img'); const previewContainer = document.getElementById('bg-image-preview'); const removeBtn = document.getElementById('remove-bg'); if (preview && previewContainer && removeBtn) { preview.src = this.currentBgImage; previewContainer.style.display = 'block'; removeBtn.style.display = 'inline-flex'; } } } // ============================================ // 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; } // Load existing data from localStorage before switching to Nextcloud // This prevents data loss when connecting to Nextcloud for the first time const existingTeas = localStorage.getItem(STORAGE_KEY_TEAS); const existingEntries = localStorage.getItem(STORAGE_KEY_ENTRIES); if (existingTeas) { try { this.teas = JSON.parse(existingTeas).map(tea => ({ ...tea, organic: tea.organic !== undefined ? tea.organic : false })); } catch (error) { console.error('Error loading existing teas:', error); } } if (existingEntries) { try { this.entries = JSON.parse(existingEntries); } catch (error) { console.error('Error loading existing entries:', error); } } // 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(DATA_DIRECTORY + 'teas.json', []); await this.nextcloudStorage.saveFile(DATA_DIRECTORY + '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 = `
📝
Keine Einträge vorhanden
Fange an, deinen Tee-Konsum zu tracken!
`; 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 `
${tea ? tea.name : 'Unbekannter Tee'} ${tea && tea.organic ? '🌱' : ''}
${formattedDate} ${formattedTime ? `um ${formattedTime}` : ''}
${entry.amount} Tasse${entry.amount > 1 ? 'n' : ''}${entry.teaspoons ? ` (${entry.teaspoons} TL)` : ''}
`; }).join(''); } // ============================================ // Tea Management UI // ============================================ renderTeasList() { const container = document.getElementById('teas-list'); if (!container) return; if (this.teas.length === 0) { container.innerHTML = `
🍵
Keine Tee-Sorten vorhanden
Klicke auf "Tee hinzufügen", um deine erste Sorte anzulegen
`; return; } container.innerHTML = this.teas.map(tea => `
${tea.image ? `${tea.name}` : ''}

${tea.name} ${tea.organic ? '🌱' : ''}

${this.renderStarRatingStatic(tea.rating || 3)}
${tea.type}
${tea.brand ? `
Marke: ${tea.brand}
` : ''} ${tea.description ? `
${tea.description}
` : ''}
`).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 = `
🔍
Keine Tee-Sorten gefunden
Versuche andere Suchbegriffe oder Filter
`; return; } container.innerHTML = filteredTeas.map(tea => `
${tea.image ? `${tea.name}` : ''}

${tea.name} ${tea.organic ? '🌱' : ''}

${this.renderStarRatingStatic(tea.rating || 3)}
${tea.type}
${tea.brand ? `
Marke: ${tea.brand}
` : ''} ${tea.description ? `
${tea.description}
` : ''}
`).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; document.getElementById('tea-rating-value').value = tea.rating || 3; this.renderStarRating(tea.rating || 3, 'tea-rating'); if (tea.image) { document.getElementById('tea-image-data').value = tea.image; document.getElementById('tea-image-preview-img').src = tea.image; document.getElementById('tea-image-preview').style.display = 'block'; } else { document.getElementById('tea-image-data').value = ''; document.getElementById('tea-image-preview-img').src = ''; document.getElementById('tea-image-preview').style.display = 'none'; } 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 = ''; document.getElementById('tea-rating-value').value = 3; this.renderStarRating(3, 'tea-rating'); document.getElementById('tea-image-data').value = ''; document.getElementById('tea-image-preview-img').src = ''; document.getElementById('tea-image-preview').style.display = 'none'; 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, rating: parseInt(document.getElementById('tea-rating-value').value) || 3, image: document.getElementById('tea-image-data').value || '' }; 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(); } handleStarRatingClick(e) { const star = e.target.closest('.star'); if (!star) return; const value = parseInt(star.dataset.value); const ratingContainer = document.getElementById('tea-rating'); const ratingInput = document.getElementById('tea-rating-value'); if (ratingContainer && ratingInput) { // Update active stars ratingContainer.querySelectorAll('.star').forEach((s, index) => { s.classList.toggle('active', index < value); }); // Set the hidden input value ratingInput.value = value; } } handleImageUpload(e) { const file = e.target.files[0]; if (!file || !file.type.startsWith('image/')) return; const preview = document.getElementById('tea-image-preview-img'); const previewContainer = document.getElementById('tea-image-preview'); const imageDataInput = document.getElementById('tea-image-data'); if (preview && previewContainer && imageDataInput) { const reader = new FileReader(); reader.onload = (event) => { preview.src = event.target.result; previewContainer.style.display = 'block'; imageDataInput.value = event.target.result; }; reader.readAsDataURL(file); } } renderStarRating(rating, containerId) { const container = document.getElementById(containerId); if (!container) return; const stars = []; for (let i = 1; i <= 5; i++) { stars.push(``); } container.innerHTML = stars.join(''); } renderStarRatingStatic(rating) { const stars = []; for (let i = 1; i <= 5; i++) { stars.push(``); } return stars.join(''); } 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 = ''; } else { teaSelect.innerHTML = '' + this.teas.map(tea => `` ).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 teaspoons = document.getElementById('track-teaspoons').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, teaspoons, 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 = `
📝
Keine Einträge vorhanden
Fange an, deinen Tee-Konsum zu tracken!
`; 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 `
${tea ? tea.name : 'Unbekannter Tee'} ${tea && tea.organic ? '🌱' : ''}
${formattedDate} ${formattedTime ? `um ${formattedTime}` : ''}
${entry.amount} Tasse${entry.amount > 1 ? 'n' : ''}${entry.teaspoons ? ` (${entry.teaspoons} TL)` : ''}
`; }).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 = `
Gesamt getrunken
${totalCups} Tassen
Durchschnitt pro Tag
${avgDaily} Tassen
Aktive Tage
${uniqueDays} Tage
Verschiedene Tees
${teaCount} Sorten
${mostConsumed.name ? `
Meist getrunken
${mostConsumed.name}
Favorit (Anzahl)
${mostConsumed.count} Tassen
` : ''} `; } // ============================================ // 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; });